commit f8cab7f1bec3dba8eb989da9adf5b811618937a4 Author: OpenClaw Agent Date: Fri Feb 6 20:40:41 2026 +0000 feat: Initial commit for contraband ranking system diff --git a/README.md b/README.md new file mode 100644 index 0000000..98170b9 --- /dev/null +++ b/README.md @@ -0,0 +1,411 @@ +# 违禁品查获排行榜系统 - 开发文档 + +## 1. 项目概述 +“违禁品查获排行榜系统”是一个用于记录、统计和展示安检团队违禁品查获数据的应用。它包含一个Web界面用于实时展示排行榜,以及一个后端脚本用于数据管理和通知。系统设计旨在提供直观的排行榜展示、便捷的数据管理和自动化的通知功能。 + +## 2. 逻辑实现 + +### 2.1 架构概览 +项目采用前后端分离的轻量级架构: +- **数据管理层**: `/root/.openclaw/workspace/contraband_manager/update_security_ranking_v2.py` 负责数据的增删改查、人员管理、数据备份和企业微信通知。 +- **展示层**: `/root/.openclaw/workspace/contraband_manager/app.py` 是一个基于 Flask 的Web应用,负责从数据文件读取数据并渲染到前端页面,提供实时的排行榜展示和查获详情。 +- **数据存储**: 使用 `security_data_v2.json` 作为主要数据存储文件。 +- **前端界面**: `/root/.openclaw/workspace/contraband_manager/templates/index.html` 使用HTML、CSS和JavaScript实现响应式设计,提供“本月榜”和“总榜”切换、三甲配色、列表渐入动画、数字滚动特效和详情弹窗功能。 + +### 2.2 数据管理脚本 (`update_security_ranking_v2.py`) +该脚本是系统的核心数据处理器,提供命令行接口进行数据操作。 + +#### 2.2.1 核心功能 +- **数据加载与保存**: `load_data()` 和 `save_data()` 函数负责从 `security_data_v2.json` 加载和保存数据。`save_data()` 会自动更新元数据(如脚本版本、最后更新时间)并触发异步备份到WebDAV。 +- **人员管理**: + - `add_member(name_with_shift)`: 新增活跃人员,支持“姓名(班次)”格式。 + - `remove_member(name)`: 将人员标记为“离职”状态,不再参与排名,但历史记录保留。 +- **记录管理**: + - `record_entry(name, content, biz_date_manual)`: 录入新的查获记录。支持手动指定业务日期,否则根据当前时间(凌晨0-5点算前一日)自动判定。每条记录会被分配一个唯一ID。 + - `delete_entry(log_id)`: 根据记录ID,将记录标记为“已删除”,数据不会真正移除,但不会在排名和查询中显示。 +- **查询功能**: + - `query_logs(mode, value)`: 根据姓名或日期查询查获记录。 +- **排行榜统计**: + - `get_ranking(target_month)`: 统计指定月份或当前月份(根据业务日期逻辑)的违禁品查获排行榜。 +- **企业微信通知**: `send_notification(action_text, data)` 函数在每次数据更新后,异步发送包含最新排名和最近动态的企业微信通知。通知内容经过格式化,支持姓名对齐。 +- **WebDAV 备份**: `upload_to_webdav(local_path, remote_sub_path)` 函数负责将 `security_data_v2.json` 和脚本自身备份到WebDAV服务器,确保数据和代码版本安全。 + +#### 2.2.2 业务日期修正逻辑 +- 脚本内定义了北京时区 (`BEIJING_TZ`)。 +- `record_entry` 函数在自动判定业务日期时,如果当前时间是当日00:00到05:00之间,则记录归属于前一个日历日。这有助于正确处理跨夜班的查获记录。 + +### 2.3 Web展示应用 (`app.py`) +这是一个轻量级的Flask应用,专注于数据展示。 + +#### 2.3.1 核心功能 +- **Flask 应用初始化**: 创建Flask应用实例。 +- **数据加载与处理**: `load_and_process_data()` 函数负责从 `security_data_v2.json` 读取数据。它将原始数据处理成前端所需的“月榜”和“总榜”格式,并对人员按分数降序排序。 + - **“月榜”**: 仅统计当月有查获记录的活跃人员。 + - **“总榜”**: 统计所有活跃人员(包括0记录),显示他们的总查获数。 +- **路由**: + - `/` (根路径): 渲染 `index.html` 模板,并将处理后的排行榜数据以JSON格式嵌入到页面中。 + - `/api/rankings`: 提供一个API接口,返回纯JSON格式的排行榜数据,供前端进行异步刷新。 +- **环境变量支持**: `DATA_FILE`、`FLASK_DEBUG`、`FLASK_HOST` 和 `FLASK_PORT` 可以通过环境变量进行配置。 + +### 2.4 前端界面 (`templates/index.html`) +采用响应式设计,提供良好的移动端体验。 + +#### 2.4.1 主要组件与功能 +- **头部 (Header)**: 显示应用标题和副标题。 +- **Tab 切换**: “本月”和“总榜”两个Tab按钮,用于切换排行榜显示。 +- **列表容器**: 动态加载和显示排行榜列表。 +- **列表项 (Item)**: + - 显示排名、姓名、班次和查获票数。 + - 前三名有特殊的金/银/铜配色和阴影效果。 + - 列表项支持点击,会弹出详情弹窗。 + - 列表项采用渐入动画 (`fadeInUp`)。 + - 查获票数有数字滚动动画 (`countPop`)。 +- **详情弹窗 (Modal)**: + - 点击列表项后弹出,显示该人员的所有查获记录详情。 + - 详情内容中的特定关键词(如“打火机”、“管制刀具”等)会被高亮显示。 + - 支持点击弹窗外部或关闭按钮来关闭。 + - 详情列表项也有渐入动画 (`fadeInDetail`)。 +- **数据嵌入与异步刷新**: + - 初始数据通过 `data_json` 变量从Flask后端嵌入到HTML中。 + - 页面每15秒通过 `/api/rankings` 接口异步获取最新数据并刷新列表,实现实时更新。 +- **空状态**: 当无数据时,显示友好的“暂无数据”提示。 + +## 3. 使用指南 + +### 3.1 数据管理 (通过命令行脚本 `update_security_ranking_v2.py`) +你可以通过在终端执行 Python 脚本来管理数据。 + +#### 3.1.1 新增成员 +格式:`python3 update_security_ranking_v2.py 新增 姓名(班次)` +示例: +```bash +python3 update_security_ranking_v2.py 新增 张三(早班) +python3 update_security_ranking_v2.py 新增 李四(晚班) +``` + +#### 3.1.2 去除成员 (标记为离职) +格式:`python3 update_security_ranking_v2.py 去除 姓名` +示例: +```bash +python3 update_security_ranking_v2.py 去除 张三 +``` +被去除的成员将不再参与排名,但历史记录仍保留。 + +#### 3.1.3 录入查获记录 +格式:`python3 update_security_ranking_v2.py 姓名 查获内容` +示例: +```bash +python3 update_security_ranking_v2.py 张三 打火机2个 +python3 update_security_ranking_v2.py 李四 仿真枪1把 +``` +**注意**: +- 脚本会自动判断业务日期(凌晨0-5点算前一日)。 +- 每次录入都会增加该成员的查获票数。 + +#### 3.1.4 补录查获记录 (指定日期) +格式:`python3 update_security_ranking_v2.py 姓名 查获内容 YYYY-MM-DD` (日期格式为 `月-日`,年份默认为当前年份) +示例: +```bash +python3 update_security_ranking_v2.py 张三 不明液体1瓶 02-05 +``` +这会在当前年份的2月5日为张三补录一条记录。 + +#### 3.1.5 删除查获记录 (标记为删除) +格式:`python3 update_security_ranking_v2.py 删除 #索引号` +示例: +```bash +python3 update_security_ranking_v2.py 删除 #123 +``` +记录将被标记为删除,不再显示在排行榜和查询结果中。 + +#### 3.1.6 查询记录 +- **按姓名查询**: `python3 update_security_ranking_v2.py 违禁品查询 姓名` + 示例:`python3 update_security_ranking_v2.py 违禁品查询 张三` +- **按日期查询**: `python3 update_security_ranking_v2.py 违禁品查询 月-日` (日期格式为 `月-日`) + 示例:`python3 update_security_ranking_v2.py 违禁品查询 02-06` + +#### 3.1.7 查看排行榜 +格式:`python3 update_security_ranking_v2.py 排行榜 [月份]` +- 查看当前月份排行榜(根据业务日期逻辑):`python3 update_security_ranking_v2.py 排行榜` +- 查看指定月份排行榜:`python3 update_security_ranking_v2.py 排行榜 01` (表示1月) 或 `python3 update_security_ranking_v2.py 排行榜 2026-01` + +### 3.2 Web展示 (通过Web应用 `app.py`) +Web界面提供了排行榜的实时展示。 + +- **访问地址**: 默认通过 `http://127.0.0.1:9517` 访问。如果部署到服务器,则访问服务器IP和对应端口。 +- **功能**: + - 切换“本月”和“总榜”查看不同维度的排名。 + - 点击任意列表项可查看该人员的详细查获记录。 + - 页面每15秒自动刷新数据。 + +## 4. 部署指南 + +本系统推荐使用 `systemd` 或 `Docker` 进行部署,并可选择性地使用 `Nginx` 进行反向代理和SSL加密。 + +### 4.1 前置条件 +- Python 3.x 及 `pip`。 +- `Flask` 库 (`pip install Flask`)。 +- `requests` 库 (`pip install requests`)。 +- `supervisor` (推荐用于进程守护,或使用 `systemd`)。 +- `curl` (用于WebDAV备份)。 + +### 4.2 部署步骤 + +#### 4.2.1 准备项目文件 +将 `contraband_manager` 目录下的所有文件(包括 `app.py`, `update_security_ranking_v2.py`, `templates/` 等)上传到服务器的指定目录,例如 `/opt/contraband_ranking/`。 + +```bash +# 假设你在项目根目录 +sudo mkdir -p /opt/contraband_ranking +sudo cp -r contraband_manager/* /opt/contraband_ranking/ +sudo cp security_data_v2.json /opt/contraband_ranking/ # 如果数据文件在上一级目录 +cd /opt/contraband_ranking +pip install -r requirements.txt # 如果有requirements.txt +``` +**注意**: 确保 `security_data_v2.json` 数据文件与 `app.py` 位于同一目录,或者根据 `app.py` 中的 `DATA_FILE` 路径进行配置。建议将 `security_data_v2.json` 放在 `/opt/contraband_ranking/` 目录下。 + +#### 4.2.2 配置环境变量 +你可以在启动脚本或 `systemd` 配置中设置环境变量来覆盖默认值: +- `CONTRABAND_DATA_FILE`: 数据文件的绝对路径 (默认为 `app.py` 同目录下的 `security_data_v2.json`)。 +- `FLASK_DEBUG`: `True` 或 `False`,控制 Flask 调试模式 (默认为 `False`)。 +- `FLASK_HOST`: Flask 监听的IP地址 (默认为 `127.0.0.1`)。设置为 `0.0.0.0` 可以从外部访问。 +- `FLASK_PORT`: Flask 监听的端口 (默认为 `9517`)。 +- `WEBHOOK_URL`: 企业微信Webhook URL (在 `update_security_ranking_v2.py` 中配置)。 + +#### 4.2.3 方式一:使用 Systemd 部署 (推荐) + +##### 4.2.3.1 创建 Flask Web 应用的 Systemd 服务 +在 `/etc/systemd/system/` 目录下创建 `contraband_ranking_web.service` 文件: +```ini +[Unit] +Description=Contraband Ranking Web Application +After=network.target + +[Service] +User=root +WorkingDirectory=/opt/contraband_ranking +ExecStart=/usr/bin/python3 app.py +Restart=always +Environment="FLASK_HOST=0.0.0.0" +Environment="FLASK_PORT=9517" +Environment="CONTRABAND_DATA_FILE=/opt/contraband_ranking/security_data_v2.json" +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` +**注意**: `User` 可以改为非root用户,但需要确保该用户对 `/opt/contraband_ranking/` 目录和 `security_data_v2.json` 文件有读写权限。 + +##### 4.2.3.2 创建数据管理脚本的 Systemd Timer (可选,用于定时执行统计或清理任务) +如果你需要定时执行 `update_security_ranking_v2.py` 的某些功能(例如每日/每月统计、数据清理等),可以创建 `systemd` timer。 + +创建 `contraband_ranking_update.service`: +```ini +[Unit] +Description=Contraband Ranking Data Update +After=network.target + +[Service] +User=root +WorkingDirectory=/opt/contraband_ranking +ExecStart=/usr/bin/python3 update_security_ranking_v2.py # 这里可以添加具体参数,例如 排行榜 +StandardOutput=journal +StandardError=journal +``` + +创建 `contraband_ranking_update.timer` (例如,每天凌晨2点执行): +```ini +[Unit] +Description=Run Contraband Ranking Data Update Daily + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true + +[Install] +WantedBy=timers.target +``` + +##### 4.2.3.3 启用并启动服务 +```bash +sudo systemctl daemon-reload +sudo systemctl enable contraband_ranking_web.service +sudo systemctl start contraband_ranking_web.service +# 如果创建了timer +sudo systemctl enable contraband_ranking_update.timer +sudo systemctl start contraband_ranking_update.timer +``` + +##### 4.2.3.4 查看状态和日志 +```bash +sudo systemctl status contraband_ranking_web.service +sudo journalctl -u contraband_ranking_web.service -f +``` + +#### 4.2.4 方式二:使用 Docker 部署 (推荐,更易于环境隔离和迁移) + +##### 4.2.4.1 创建 Dockerfile +在 `/opt/contraband_ranking/` 目录下创建 `Dockerfile`: +```dockerfile +# 使用官方Python运行时作为父镜像 +FROM python:3.9-slim-buster + +# 设置工作目录 +WORKDIR /app + +# 复制requirements.txt并安装依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制项目文件 +COPY . . + +# 暴露端口 +EXPOSE 9517 + +# 定义默认命令来运行应用程序 +CMD ["python3", "app.py"] +``` +如果 `requirements.txt` 不存在,你需要根据 `app.py` 和 `update_security_ranking_v2.py` 的依赖(`Flask`, `requests`)手动创建。 + +##### 4.2.4.2 构建 Docker 镜像 +在 `/opt/contraband_ranking/` 目录下执行: +```bash +sudo docker build -t contraband-ranking-app . +``` + +##### 4.2.4.3 运行 Docker 容器 +```bash +sudo docker run -d \ + --name contraband-ranking \ + -p 9517:9517 \ + -v /opt/contraband_ranking/security_data_v2.json:/app/security_data_v2.json \ + -e FLASK_HOST=0.0.0.0 \ + -e FLASK_PORT=9517 \ + contraband-ranking-app +``` +**说明**: +- `-d`: 后台运行容器。 +- `--name contraband-ranking`: 为容器指定名称。 +- `-p 9517:9517`: 将主机的 9517 端口映射到容器的 9517 端口。 +- `-v /opt/contraband_ranking/security_data_v2.json:/app/security_data_v2.json`: 将主机上的数据文件挂载到容器内部,以便数据持久化和外部管理。 +- `-e`: 设置环境变量。 + +##### 4.2.4.4 查看 Docker 容器状态和日志 +```bash +sudo docker ps +sudo docker logs -f contraband-ranking +``` + +#### 4.2.5 使用 Nginx 进行反向代理和 HTTPS (可选,生产环境推荐) + +如果你希望通过域名访问并启用 HTTPS,可以使用 Nginx 作为反向代理。 + +##### 4.2.5.1 安装 Nginx +```bash +sudo apt update +sudo apt install nginx +``` + +##### 4.2.5.2 配置 Nginx +在 `/etc/nginx/sites-available/` 目录下创建或编辑配置文件 (例如 `contraband_ranking.conf`): +```nginx +server { + listen 80; + server_name your_domain.com; # 替换为你的域名 + + location / { + proxy_pass http://127.0.0.1:9517; # 指向你的Flask应用监听地址和端口 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 如果需要 HTTPS,使用 Certbot 配置 + # listen 443 ssl; + # ssl_certificate /etc/letsencrypt/live/your_domain.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/your_domain.com/privkey.pem; + # include /etc/letsencrypt/options-ssl-nginx.conf; + # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} +``` + +##### 4.2.5.3 启用 Nginx 配置并重启 +```bash +sudo ln -s /etc/nginx/sites-available/contraband_ranking.conf /etc/nginx/sites-enabled/ +sudo nginx -t # 测试配置 +sudo systemctl restart nginx +``` + +##### 4.2.5.4 配置 HTTPS (推荐使用 Certbot) +如果你有域名,强烈建议使用 Let's Encrypt 和 Certbot 来获取免费的 SSL 证书并配置 HTTPS。 +```bash +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d your_domain.com +``` +按照提示操作即可。Certbot 会自动修改 Nginx 配置并设置证书自动续期。 + +## 5. 数据文件结构 (`security_data_v2.json`) +```json +{ + "meta": { + "script_version": "v2.3.8", + "last_update": "2026-02-06T20:30:00+08:00" + }, + "members": { + "张三": { + "shift": "早班", + "status": "active" + }, + "李四": { + "shift": "晚班", + "status": "offboarded" + } + }, + "logs": [ + { + "id": 0, + "name": "张三", + "content": "打火机2个", + "actual_time": "2026-02-06T10:00:00+08:00", + "biz_date": "2026-02-06", + "biz_month": "2026-02", + "is_deleted": false + }, + { + "id": 1, + "name": "李四", + "content": "仿真枪1把", + "actual_time": "2026-02-06T03:00:00+08:00", + "biz_date": "2026-02-05", + "biz_month": "2026-02", + "is_deleted": false + } + ], + "last_id": 1 +} +``` +- `meta`: 包含脚本版本和最后更新时间等元数据。 +- `members`: 存储所有人员信息。 + - `name`: 成员姓名。 + - `shift`: 班次。 + - `status`: `active` (活跃) 或 `offboarded` (离职)。 +- `logs`: 存储所有查获记录。 + - `id`: 唯一记录ID。 + - `name`: 查获人员姓名。 + - `content`: 查获内容。 + - `actual_time`: 记录的实际时间(ISO格式)。 + - `biz_date`: 业务日期 (yyyy-mm-dd格式,考虑了跨夜班逻辑)。 + - `biz_month`: 业务月份 (yyyy-mm格式)。 + - `is_deleted`: 布尔值,标记记录是否被删除。 +- `last_id`: 最后一个记录的ID,用于生成新的唯一ID。 + +## 6. 维护与扩展 +- **数据备份**: `update_security_ranking_v2.py` 已集成WebDAV备份功能,请确保WebDAV配置正确。 +- **日志**: Web应用和脚本的输出都建议通过 `systemd journal` 或 Docker 日志进行监控。 +- **扩展功能**: + - 如果需要新的数据管理功能,可以直接修改 `update_security_ranking_v2.py`。 + - 如果需要新的Web展示页面或功能,可以修改 `app.py` 和 `templates/index.html`。 + - 新增关键词高亮:修改 `index.html` 中的JavaScript `replace` 正则表达式。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..b534d5e --- /dev/null +++ b/app.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +违禁品查获排行榜 - Web展示应用 +支持日榜、月榜、总榜展示,带动态筛选和可视化效果 +""" + +from flask import Flask, render_template, jsonify +import json +import os +from datetime import datetime, date + +app = Flask(__name__) + +# 数据文件路径 (支持从环境变量覆盖) +DATA_FILE = os.environ.get('CONTRABAND_DATA_FILE', + os.path.join(os.path.dirname(__file__), 'security_data_v2.json')) + + +def load_and_process_data(): + """ + 加载并处理违禁品数据 + 返回格式: {维度: {榜单列表}} + 维度: 'month', 'all' + - month: 本月统计(有记录人员) + - all: 总榜(所有在职人员,包括0记录) + """ + # 检查数据文件 + if not os.path.exists(DATA_FILE): + app.logger.warning(f"数据文件不存在: {DATA_FILE}") + return {'month': [], 'all': []} + + try: + with open(DATA_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + app.logger.error(f"数据文件解析失败: {e}") + return {'month': [], 'all': []} + + members = data.get("members", {}) + logs = data.get("logs", []) + + # 获取业务日期(处理跨夜班的情况) + today_date = date.today() + today_str = today_date.strftime("%Y-%m-%d") + current_month = today_date.strftime("%Y-%m") + + # 初始化三个维度的容器: {维度: {姓名: [明细列表]}} + categories = { + "month": {}, + "all": {} + } + + # 总榜初始化:所有在职人员(包括0记录) + for name, info in members.items(): + if info.get('status') == 'active': + categories["all"][name] = [] + + # 遍历日志进行分类 + for log in logs: + # 跳过已删除的记录 + if log.get('is_deleted'): + continue + + name = log.get('name') + biz_date = log.get('biz_date', '') + biz_month = log.get('biz_month', '') + + # 构建明细 + detail = { + "time": log.get('actual_time', ''), + "content": log.get('content', '') + } + + # 归档至"总榜"(只统计在职人员) + if name in categories["all"]: + categories["all"][name].append(detail) + + # 归档至"月榜" + if biz_month == current_month: + categories["month"].setdefault(name, []).append(detail) + + # 格式化前端所需的榜单数据 + final_data = {} + for cat, user_map in categories.items(): + sorted_rank = [] + for name, details in user_map.items(): + member_info = members.get(name, {}) + sorted_rank.append({ + "name": name, + "score": len(details), + "shift": member_info.get("shift", "未知"), + "status": member_info.get("status", "unknown"), + "details": details + }) + + # 按分数降序排序 + sorted_rank.sort(key=lambda x: x['score'], reverse=True) + final_data[cat] = sorted_rank + + return final_data + + +@app.route('/') +def index(): + """主页 - 加载所有榜单数据""" + all_data = load_and_process_data() + return render_template('index.html', + data_json=json.dumps(all_data, ensure_ascii=False)) + + +@app.route('/api/rankings') +def api_rankings(): + """API接口 - 返回JSON格式的榜单数据""" + all_data = load_and_process_data() + return jsonify(all_data) + + +if __name__ == '__main__': + # 开发环境配置 + debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() in ('true', '1', 't') + host = '127.0.0.1' + port = 9517 + + print(f"🚀 违禁品排行榜启动中...") + print(f"📁 数据文件: {DATA_FILE}") + print(f"🌐 访问地址: http://{host}:{port}") + + app.run(host=host, port=port, debug=debug_mode) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9e376ec --- /dev/null +++ b/templates/index.html @@ -0,0 +1,534 @@ + + + + + + 安检团队违禁品数据实时榜 + + + +
+
+

🏆 违禁品查获排行榜

+
实时统计 · 动态展示
+
+ +
+ + +
+ +
+ +
+
+ + + + + + + + + + + + diff --git a/update_security_ranking_v2.py b/update_security_ranking_v2.py new file mode 100644 index 0000000..ca44c25 --- /dev/null +++ b/update_security_ranking_v2.py @@ -0,0 +1,226 @@ +import json +import os +import re +from datetime import datetime, timedelta, timezone +import requests +import sys +import subprocess + +# ================= 配置区 ================= +VERSION = "v2.3.8" +WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=579c755e-24d2-47ae-9ca1-3ede6998327a" +DATA_FILE = "security_data_v2.json" +BEIJING_TZ = timezone(timedelta(hours=8)) + +# WebDAV 配置 +WEBDAV_BASE = "https://chfs.ouaone.top/webdav/openclaw/backup/security/" +WEBDAV_AUTH = "openclaw:Khh13579" + +# ================= 核心工具 ================= + +def get_now(): + return datetime.now(BEIJING_TZ) + +def upload_to_webdav(local_path, remote_sub_path): + remote_url = f"{WEBDAV_BASE}{remote_sub_path}" + if "/" in remote_sub_path: + parent_dir = "/".join(remote_sub_path.split("/")[:-1]) + mkdir_cmd = f"curl -u '{WEBDAV_AUTH}' -X MKCOL '{WEBDAV_BASE}{parent_dir}/' >/dev/null 2>&1" + subprocess.run(mkdir_cmd, shell=True) + cmd = f"curl -u '{WEBDAV_AUTH}' -T '{local_path}' '{remote_url}'" + subprocess.Popen(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def load_data(): + data = {"meta": {"script_version": VERSION}, "members": {}, "logs": [], "last_id": -1} + if os.path.exists(DATA_FILE): + with open(DATA_FILE, 'r', encoding='utf-8') as f: + loaded = json.load(f) + if "meta" not in loaded: loaded["meta"] = data["meta"] + return loaded + return data + +def save_data(data, action_text="系统自动备份"): + if "meta" not in data: data["meta"] = {} + data["meta"]["script_version"] = VERSION + data["meta"]["last_update"] = get_now().isoformat() + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + # 异步备份 + upload_to_webdav(DATA_FILE, "latest_security_v2.json") + upload_to_webdav(__file__, f"script_history/script_{VERSION}.py") + # 触发新版快报推送 + send_notification(action_text, data) + +def format_name(name): + """两个字姓名中间加4个空格以对齐三字姓名""" + return f"{name[0]} {name[1]}" if len(name) == 2 else name + +def send_notification(action_text, data): + """发送企业微信通知 - v2.3.8 对齐增强与删除功能 + 新业务日期""" + if not WEBHOOK_URL: return + now = get_now().strftime("%Y-%m-%d %H:%M") + biz_month = get_now().strftime("%Y-%m") + + # 1. 统计当月排名 (过滤0票) + stats = {} + for log in data.get("logs", []): + # 只有未删除的记录才参与排名 + if log.get("biz_month") == biz_month and not log.get("is_deleted", False): + n = log["name"] + if n in data["members"] and data["members"][n]["status"] == "active": + stats[n] = stats.get(n, 0) + 1 + + ranking = sorted(stats.items(), key=lambda x: x[1], reverse=True) + # 此处调用 format_name 进行名字对齐 + ranking_text = "\n".join([f"{i+1}. {format_name(m[0])}: {m[1]} 票({data['members'][m[0]].get('shift', '未知')})" for i, m in enumerate(ranking)]) + + # 2. 获取最近动态 (只显示未删除的) + recent_records = [l for l in data.get('logs', []) if not l.get('is_deleted', False)][-3:] + recent_records.reverse() + recent_text = "" + for r in recent_records: + r_time = datetime.fromisoformat(r['actual_time']).astimezone(BEIJING_TZ).strftime("%m-%d %H:%M") + # 此处也应用对齐逻辑 + recent_text += f"- [{r_time}] {format_name(r['name'])}: {r['content']}\n" + + content = f"""安检团队查获快报 +更新时间:{now} +最新操作: {action_text} + +排名明细: +{ranking_text if ranking_text else "暂无记录"} +(仅显示已查获成员,0 记录成员已隐藏) + +最近动态: +{recent_text if recent_text else "暂无记录"}""" + + payload = {"msgtype": "text", "text": {"content": content}} + try: requests.post(WEBHOOK_URL, json=payload, timeout=5) + except: pass + +def add_member(name_with_shift): + m = re.match(r"^(.*?)\((.*?)\)$", name_with_shift) + if not m: return "错误:格式应为 '新增 姓名(班次)'" + name, shift = m.groups() + data = load_data() + if name in data["members"] and data["members"][name]["status"] == "active": + return f"ℹ️ 人员 {name} 已存在且为活跃状态。" + data["members"][name] = {"shift": shift, "status": "active"} + action_text = f"新增成员 {format_name(name)} ({shift})" + save_data(data, action_text) + return f"✅ 已录入人员:{name} ({shift})" + +def remove_member(name): + data = load_data() + if name not in data["members"] or data["members"][name]["status"] == "offboarded": + return f"ℹ️ 未找到活跃人员 '{name}' 或已离职。" + data["members"][name]["status"] = "offboarded" + action_text = f"去除成员 {format_name(name)}" + save_data(data, action_text) + return f"✅ 已去除人员:{name} (已标记为离职,不参与排名)" + +def record_entry(name, content, biz_date_manual=None): + data = load_data() + if name not in data["members"] or data["members"][name]["status"] == "offboarded": + return f"错误:未找到活跃人员 '{name}' 或该人员已离职。" + + now = get_now() + if biz_date_manual: + year = now.year + dt = datetime.strptime(f"{year}{biz_date_manual.replace('-','')}", "%Y%m%d").replace(tzinfo=BEIJING_TZ) + info = {"biz_date": dt.strftime("%Y-%m-%d"), "biz_month": dt.strftime("%Y-%m"), "is_manual": True} + else: + # 修正业务日期判定逻辑:00:00-05:00 归属到前一个日历日 + # 重点:判断当前小时是否在 0 到 5 之间(包含 0 和 5) + biz_dt = now - timedelta(days=1) if 0 <= now.hour <= 5 else now + info = {"biz_date": biz_dt.strftime("%Y-%m-%d"), "biz_month": biz_dt.strftime("%Y-%m"), "is_manual": False} + + data["last_id"] += 1 + new_id = data["last_id"] + log = {"id": new_id, "name": name, "content": content, "actual_time": now.isoformat(), "biz_date": info["biz_date"], "biz_month": info["biz_month"]} + data["logs"].append(log) + + tag = "[补录] " if info.get("is_manual") else "" + action_text = f"{tag}新增 {format_name(name)} {content}" + save_data(data, action_text) + + daily_count = sum(1 for l in data["logs"] if l["name"] == name and l["biz_date"] == info["biz_date"] and not l.get('is_deleted', False)) + return f"[已录入] {name} - {content} (当日第{daily_count}票, 索引 #{new_id})" + +def delete_entry(log_id): + data = load_data() + found = False + for log in data["logs"]: + if log['id'] == log_id: + log['is_deleted'] = True # 标记为删除,而不是真正移除 + found = True + break + if found: + action_text = f"删除记录 # {log_id}" + save_data(data, action_text) + return f"✅ 已删除记录:#{log_id}" + return f"❌ 未找到索引为 #{log_id} 的记录。" + +def query_logs(mode, value): + data = load_data() + results = [l for l in data["logs"] if (l["name"] == value if mode=="name" else l["biz_date"].endswith(value)) and not l.get('is_deleted', False)] + if not results: return "🔍 暂无记录。" + res = f"🔍 查询结果 ({value})\n----------------\n" + for l in results: + time_str = datetime.fromisoformat(l['actual_time']).astimezone(BEIJING_TZ).strftime("%m-%d %H:%M") + res += f"#{l['id']} - {l['content']} (录入时间: {time_str})\n" + return res + +def get_ranking(target_month=None): + data = load_data() + now = get_now() + if not target_month: + target_month = (now.replace(day=1)-timedelta(days=1)).strftime("%Y-%m") if 0<=now.hour<6 else now.strftime("%Y-%m") + elif len(target_month) <= 3: + target_month = f"{now.year}-{target_month.replace('月','').zfill(2)}" + + stats = {} + for log in data.get("logs", []): + if log.get("biz_month") == target_month and not log.get('is_deleted', False): + n = log["name"] + if n in data["members"] and data["members"][n]["status"] == "active": + stats[n] = stats.get(n, 0) + 1 + + sorted_ranking = sorted(stats.items(), key=lambda x: x[1], reverse=True) + res = f"🏆 安检排行榜 ({target_month}) [v{VERSION}]\n----------------\n" + if not sorted_ranking: return res + "暂无记录。" + for i, (name, count) in enumerate(sorted_ranking): + res += f"{i+1}. {format_name(name)}: {count} 票 ({data['members'][name]['shift']})\n" + return res + +if __name__ == "__main__": + args = sys.argv[1:] + # print(f"DEBUG: Raw args received: {args}") # 再次调试打印 + + if not args: print(f"安检系统 {VERSION}"); sys.exit(0) + + # 优先检查去除成员命令 + if len(args) >= 2 and args[0] == "去除": + print(remove_member(args[1])) + # 优先检查新增成员命令 + elif len(args) >= 2 and args[0] == "新增": + print(add_member(" ".join(args[1:]))) + # 优先检查删除命令,直接从 args 列表解析 + elif len(args) >= 2 and args[0] == "删除" and args[1].startswith("#"): + try: + log_id = int(args[1][1:].strip()) + print(delete_entry(log_id)) + except ValueError: print("错误:删除指令格式为 '删除 #索引号'") + # 检查排行榜命令 + elif "排行榜" in " ".join(args): + m = re.search(r"排行榜\s*(.*)", " ".join(args)) + print(get_ranking(m.group(1).strip() if m.group(1) else None)) + # 检查违禁品查询命令 + elif len(args) >= 2 and args[0].startswith("违禁品"): + val = " ".join(args[1:]).strip() + print(query_logs("date", val) if re.match(r"^\d{2}-?\d{2}$", val) else query_logs("name", val)) + # 默认处理记录录入 + elif len(args) >= 2: + print(record_entry(args[0], " ".join(args[1:]))) + else: + print("错误:格式不识别")