feat: Initial commit for contraband ranking system
This commit is contained in:
411
README.md
Normal file
411
README.md
Normal file
@@ -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` 正则表达式。
|
||||
128
app.py
Normal file
128
app.py
Normal file
@@ -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)
|
||||
534
templates/index.html
Normal file
534
templates/index.html
Normal file
@@ -0,0 +1,534 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>安检团队违禁品数据实时榜</title>
|
||||
<style>
|
||||
:root {
|
||||
--main-red: #ff4d4f;
|
||||
--gold: #faad14;
|
||||
--silver: #76838b;
|
||||
--bronze: #d48806;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hot-list-card {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.header {
|
||||
padding: 25px 20px;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* Tabs 样式 */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #262626;
|
||||
padding: 8px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
color: #8c8c8c;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #bfbfbf;
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #fff;
|
||||
background: var(--main-red);
|
||||
box-shadow: 0 4px 15px rgba(255, 77, 79, 0.4);
|
||||
}
|
||||
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 列表容器 */
|
||||
.list-container {
|
||||
min-height: 400px;
|
||||
padding: 10px 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 列表项样式 */
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
animation: fadeInUp 0.5s ease backwards;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #f8f9fa;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 排名标签 */
|
||||
.rank-tag {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
font-family: Arial, sans-serif;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #e0e0e0 0%, #bdbdbd 100%);
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-1 {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffb800 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(250, 173, 20, 0.5);
|
||||
}
|
||||
|
||||
.rank-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #a0a0a0 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(118, 131, 139, 0.5);
|
||||
}
|
||||
|
||||
.rank-3 {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #b87333 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(212, 136, 6, 0.5);
|
||||
}
|
||||
|
||||
/* 信息区域 */
|
||||
.info {
|
||||
flex: 1;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 分数区域 */
|
||||
.score-area {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.count-num {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--main-red);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.3s ease;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--main-red) 0%, #ff7a45 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
animation: fadeInDetail 0.3s ease backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInDetail {
|
||||
from { opacity: 0; transform: translateX(-10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #fff;
|
||||
background: var(--main-red);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 数字滚动动画 */
|
||||
.count-animate {
|
||||
animation: countPop 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
@keyframes countPop {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 480px) {
|
||||
.hot-list-card {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.rank-tag {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.count-num {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hot-list-card">
|
||||
<div class="header">
|
||||
<h1>🏆 违禁品查获排行榜</h1>
|
||||
<div class="subtitle">实时统计 · 动态展示</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="month">本月</button>
|
||||
<button class="tab-btn" data-tab="all">总榜</button>
|
||||
</div>
|
||||
|
||||
<div class="list-container" id="listContainer">
|
||||
<!-- 列表将通过 JS 动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<div class="modal" id="detailModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">姓名</h2>
|
||||
<button class="close-btn" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 详情将通过 JS 动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="data-json" type="application/json">{{ data_json|safe }}</script>
|
||||
|
||||
<script>
|
||||
// 全局数据
|
||||
let allData = {};
|
||||
let currentTab = 'month';
|
||||
|
||||
// DOM加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 加载数据
|
||||
const dataJsonElement = document.getElementById('data-json');
|
||||
if (dataJsonElement) {
|
||||
allData = JSON.parse(dataJsonElement.textContent);
|
||||
// 初始渲染
|
||||
renderList('month');
|
||||
}
|
||||
});
|
||||
|
||||
// Tab 切换
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const tab = this.dataset.tab;
|
||||
|
||||
// 更新按钮状态
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// 切换数据
|
||||
currentTab = tab;
|
||||
renderList(tab);
|
||||
});
|
||||
});
|
||||
|
||||
// 渲染列表
|
||||
function renderList(tab) {
|
||||
const container = document.getElementById('listContainer');
|
||||
const data = allData[tab] || [];
|
||||
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="icon">📭</div>
|
||||
<div>暂无数据</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
data.forEach((item, index) => {
|
||||
const rank = index + 1;
|
||||
const rankClass = rank <= 3 ? `rank-${rank}` : '';
|
||||
|
||||
html += `
|
||||
<div class="item" style="animation-delay: ${index * 0.05}s" onclick="showDetail('${item.name}')">
|
||||
<div class="rank-tag ${rankClass}">${rank}</div>
|
||||
<div class="info">
|
||||
<div class="name">${item.name}</div>
|
||||
<div class="meta">${item.shift}</div>
|
||||
</div>
|
||||
<div class="score-area">
|
||||
<div class="count-num${index === 0 ? ' count-animate' : ''}">${item.score}</div>
|
||||
<div class="count-label">票</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
function showDetail(name) {
|
||||
const data = allData[currentTab] || [];
|
||||
const user = data.find(u => u.name === name);
|
||||
|
||||
if (!user) return;
|
||||
|
||||
document.getElementById('modalTitle').textContent = user.name;
|
||||
|
||||
const body = document.getElementById('modalBody');
|
||||
if (user.details && user.details.length > 0) {
|
||||
let html = '';
|
||||
user.details.forEach((detail, index) => {
|
||||
const time = new Date(detail.time).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// 高亮关键词
|
||||
let content = detail.content
|
||||
.replace(/(打火机|火苗|压力罐|仿真枪|管制刀具|不明药丸|不明液体|精神药品|流通货币)/g,
|
||||
'<span class="highlight">$1</span>');
|
||||
|
||||
html += `
|
||||
<div class="detail-item" style="animation-delay: ${index * 0.05}s">
|
||||
<div class="detail-time">${time}</div>
|
||||
<div class="detail-content">${content}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
body.innerHTML = html;
|
||||
} else {
|
||||
body.innerHTML = '<div class="detail-item">暂无详情记录</div>';
|
||||
}
|
||||
|
||||
document.getElementById('detailModal').classList.add('active');
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// 点击弹窗外部关闭
|
||||
document.getElementById('detailModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 定时刷新(每15秒)
|
||||
setInterval(() => {
|
||||
fetch('/api/rankings')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
allData = data;
|
||||
renderList(currentTab);
|
||||
})
|
||||
.catch(err => console.error('刷新失败:', err));
|
||||
}, 15000);
|
||||
</script>
|
||||
|
||||
<!-- 数据传递到 JS -->
|
||||
<script id="data-json" type="application/json">{{ data_json|safe }}</script>
|
||||
</body>
|
||||
</html>
|
||||
226
update_security_ranking_v2.py
Normal file
226
update_security_ranking_v2.py
Normal file
@@ -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("错误:格式不识别")
|
||||
Reference in New Issue
Block a user