feat: initial import (exclude templates and runtime temp files)
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Template assets (exclude from repo)
|
||||||
|
/templates/
|
||||||
|
|
||||||
|
# Python cache / bytecode
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Runtime/temp artifacts
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Generated runtime data
|
||||||
|
/app/output/
|
||||||
|
/app/data/generated_history.json
|
||||||
|
/app/data/review_logs/
|
||||||
|
/app/data/wechat_bridge_state.json
|
||||||
|
/app/data/wechat_bridge_meta.json
|
||||||
|
/app/data/marked_issues.json
|
||||||
|
/app/data/skipped_suppressions.json
|
||||||
|
/app/data/manual_rules.json
|
||||||
|
|
||||||
|
# Node / package temp
|
||||||
|
/app/node_modules/
|
||||||
|
|
||||||
|
# Env and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
15
app/Makefile
Normal file
15
app/Makefile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.PHONY: lint test smoke check
|
||||||
|
|
||||||
|
lint:
|
||||||
|
node --check static/app.js
|
||||||
|
node --check static/js/core/state.js
|
||||||
|
node --check static/js/main.js
|
||||||
|
python3 -m py_compile server.py api_get_routes.py api_post_routes.py services/workflows.py services/post_ops.py repositories/history_repository.py wechat_bot_bridge.py
|
||||||
|
|
||||||
|
test:
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
smoke:
|
||||||
|
bash scripts/smoke_api.sh
|
||||||
|
|
||||||
|
check: lint test smoke
|
||||||
294
app/README.md
Normal file
294
app/README.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# 喜报处理 Web
|
||||||
|
|
||||||
|
按 `config/xibao_logic.json` 解析接龙文本,批量生成喜报图片并下载。
|
||||||
|
|
||||||
|
## 项目情况(2026-02-24)
|
||||||
|
|
||||||
|
- 线上域名:`https://zc.workyai.cn`
|
||||||
|
- Nginx 转发:`zc.workyai.cn -> 127.0.0.1:8787`
|
||||||
|
- 运行服务:`xibao-web.service`
|
||||||
|
- 代码目录:`/opt/xibao-web/app`
|
||||||
|
- Python 环境:`/opt/xibao-web/venv`
|
||||||
|
|
||||||
|
## 当前能力
|
||||||
|
|
||||||
|
- 根据 `#接龙` 文本逐行解析。
|
||||||
|
- 支持一条文本拆分多个产品(如同一行里的定期 + 理财)。
|
||||||
|
- 支持别名映射(如 `中心所 -> 潇水南路`、`营江路所 -> 营江路`)。
|
||||||
|
- 只保留 13 个白名单网点,不在名单内自动跳过。
|
||||||
|
- 保险默认期交(`page_3`),若未写明年限会要求选择 `3年交 / 5年交`。
|
||||||
|
- 仅“微信提现12万”这类未写明确期限的记录,按活期场景处理,默认不生成。
|
||||||
|
- 金额识别支持阿拉伯数字和常见中文写法。
|
||||||
|
- 中文金额口语兼容:`十万`、`两万五`、`一万二`、`两万五千`、`二点五万`。
|
||||||
|
- 金额统一按现有规则向下取整到 `N万`(例如 `2.5万 -> 2万`、`7.8万 -> 7万`)。
|
||||||
|
- 去重策略:有序号时优先按“序号+行内产品序位+行内容指纹”去重;无序号时回退 `branch+amount+type`(历史去重 + 本次输入去重)。
|
||||||
|
- 每条数据生成独立 PPT,并按其 `page_x` 只导出对应页 PNG。
|
||||||
|
- 多张 PNG 前端逐图下载(无需解压)。
|
||||||
|
- 每天 00:00 自动清理 `output/` 下历史截图任务。
|
||||||
|
- 支持手工强制清理截图(与 00:00 自动清理同逻辑)。
|
||||||
|
- 支持复盘日志(手工标记识别错/生成错、自动记录解析跳过与异常)。
|
||||||
|
- 标记识别错/生成错时支持填写备注(前端弹窗输入)。
|
||||||
|
- 支持“已标识错误”列表查看、编辑、删除。
|
||||||
|
- 手动“修正并生成”成功后,会自动清除同原始行的活跃标识(转为已解决)。
|
||||||
|
- 重复记录支持直接预览与下载(若历史图片仍存在)。
|
||||||
|
- 生成时可查看实时进度(解析、生成PPT、转PDF、导图、收尾)。
|
||||||
|
- 刷新页面后可查看历史记录并继续标注(支持历史图预览/下载)。
|
||||||
|
- 生成阶段支持低配模式:默认单任务单并发(同一时刻只允许一个生成任务)。
|
||||||
|
- 生成策略支持“页面模板缓存”(默认):多条生成时先按 `page_x` 预构建单页模板,单线程落盘以降低峰值内存。
|
||||||
|
- 生成完成后启用内存回收(`gc.collect + malloc_trim`,可配置开关)。
|
||||||
|
- 字体保护不变:仍按 run 级别替换文本,仅改数字/状态/网点目标 run,不改字体样式与字号。
|
||||||
|
- 预览图按顺序逐张加载(前面的先加载),避免大批量并发请求导致预览异常。
|
||||||
|
- 图片下载接口支持限速(默认 `300KB/s`),可在 `performance.image_delivery.max_kbps` 调整。
|
||||||
|
- 跳过明细支持“屏蔽”单条(屏蔽后不再展示,清空历史时一并清空屏蔽)。
|
||||||
|
|
||||||
|
## 最近修复
|
||||||
|
|
||||||
|
- 修复“识别正确但生成网点变成潇水南路”的问题。
|
||||||
|
- 原因是模板不同页面的网点文本框索引不一致,旧逻辑固定 `shape_index=3`。
|
||||||
|
- 现逻辑会优先定位包含“营业所”的文本段再替换,避免错位替换。
|
||||||
|
- 修复中文金额 `amount_not_found`(例如 `蚣坝揽收十万现金定期一年`)。
|
||||||
|
- 新增中文金额解析后,补充了兼容保护,避免把单独“万/亿”误识别成 `0万`。
|
||||||
|
- 状态识别增强:支持“关键词中间夹金额/文本”的句式(如 `揽收十万现金`、`揽收16万礼金`、`微信17万提现`、`商户20万提现`、`揽收20万商户`)。
|
||||||
|
- 理财页文案调整为 `两三年期理财`,并在生成时对标题文本关闭自动换行,避免多字后折行。
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
- PPT 文本替换:`python-pptx`
|
||||||
|
- PPT 转 PDF、PDF 按页转 PNG:优先本机 `libreoffice + pdftoppm`,无本机环境时回退 Docker `minidocks/libreoffice`
|
||||||
|
- 生成链路:`解析 -> 页面模板缓存(可配置) -> 生成PPT(默认单线程) -> 批量转PDF -> PDF导图(默认单线程)`
|
||||||
|
- 前端迁移策略:主页面已直接接入 Vue(`static/js/main.js`),不再依赖 legacy 保底脚本。
|
||||||
|
- Vue 运行时采用本地静态文件:`static/vendor/vue.global.prod.js`(由 `npm run sync:vue` 同步)。
|
||||||
|
|
||||||
|
默认会在启动时后台预热转换镜像;若已完成部署初始化,首次生成通常不会再等待拉取镜像。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `server.py`: 后端(标准库 HTTP API + 生成逻辑)
|
||||||
|
- `api_get_routes.py`: GET 接口路由分发(从 `server.py` 抽离)
|
||||||
|
- `api_post_routes.py`: POST 接口路由分发(从 `server.py` 抽离)
|
||||||
|
- `services/workflows.py`: 解析/生成/修正业务流程服务层
|
||||||
|
- `services/post_ops.py`: 标识/历史/清理等通用 POST 业务服务层
|
||||||
|
- `repositories/history_repository.py`: 历史读写仓储层
|
||||||
|
- `static/index.html`: 前端页面
|
||||||
|
- `static/app.js`: 前端加载入口(仅负责加载脚本)
|
||||||
|
- `static/js/core/state.js`: 前端共享状态与常量
|
||||||
|
- `static/js/main.js`: 主前端脚本(Vue + 业务逻辑)
|
||||||
|
- `static/vendor/vue.global.prod.js`: 本地 Vue 运行时
|
||||||
|
- `static/styles.css`: 页面样式
|
||||||
|
- `config/xibao_logic.json`: 处理规则配置
|
||||||
|
- `data/generated_history.json`: 去重历史
|
||||||
|
- `data/manual_rules.json`: 手工规则
|
||||||
|
- `data/review_logs/review_YYYY-MM-DD.jsonl`: 当日复盘日志
|
||||||
|
- `output/`: 生成输出目录(每次任务图片)
|
||||||
|
- `tests/`: 自动化单元测试
|
||||||
|
- `scripts/smoke_api.sh`: 接口烟雾测试脚本
|
||||||
|
- `package.json`: 前端工具脚本(`lint/test/smoke/sync:vue`)
|
||||||
|
- `Makefile`: 一键检查入口(`make check`)
|
||||||
|
- `app.js`: 历史遗留文件,不参与线上页面加载
|
||||||
|
|
||||||
|
## 启动与运维
|
||||||
|
|
||||||
|
### 本地直接启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/xibao-web/app
|
||||||
|
/opt/xibao-web/venv/bin/python server.py --host 127.0.0.1 --port 8787
|
||||||
|
```
|
||||||
|
|
||||||
|
可选参数:
|
||||||
|
|
||||||
|
- `--prewarm-blocking`: 启动前阻塞预热镜像(确保服务起来后镜像一定可用)
|
||||||
|
- `--skip-prewarm`: 跳过启动时预热
|
||||||
|
|
||||||
|
### 开发检查(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/xibao-web/app
|
||||||
|
|
||||||
|
# 1) 语法/静态检查
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# 2) 单元测试
|
||||||
|
make test
|
||||||
|
|
||||||
|
# 3) 线上接口烟雾回归(默认 zc.workyai.cn)
|
||||||
|
make smoke
|
||||||
|
|
||||||
|
# 一键全跑
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
|
前端工具脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 同步本地 Vue 运行时到 static/vendor
|
||||||
|
npm run sync:vue
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd(线上)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status xibao-web.service
|
||||||
|
systemctl restart xibao-web.service
|
||||||
|
journalctl -u xibao-web.service -n 200 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx(线上)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /etc/nginx/sites-available/zc.workyai.cn
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## API(核心)
|
||||||
|
|
||||||
|
- `GET /api/config`: 获取配置摘要
|
||||||
|
- `POST /api/parse`: 仅解析预览
|
||||||
|
- `POST /api/generate`: 解析 + 生成 + 返回图片下载地址
|
||||||
|
- `GET /api/download/{token}`: 下载单张图片
|
||||||
|
- `GET /api/progress/{token}`: 查询本次生成进度
|
||||||
|
- `POST /api/output/clear`: 强制清理输出目录截图/任务文件
|
||||||
|
- `GET /api/history/view?limit=500`: 获取可预览/下载的历史记录视图
|
||||||
|
- `POST /api/log/mark`: 标记识别错误/生成错误到复盘日志(支持 `note` 备注)
|
||||||
|
- `GET /api/issues?status=active|resolved|all&limit=500`: 查看标识列表
|
||||||
|
- `POST /api/issues/update`: 修改标识(类型/原始行/备注)
|
||||||
|
- `POST /api/issues/delete`: 删除标识
|
||||||
|
- `GET /api/log/today`: 查看今日日志
|
||||||
|
- `POST /api/history/clear`: 清空历史
|
||||||
|
|
||||||
|
## 微信机器人接入(新增)
|
||||||
|
|
||||||
|
项目内置桥接脚本:`wechat_bot_bridge.py`,用于把微信消息接入到本项目:
|
||||||
|
|
||||||
|
- 拉取微信新消息:`/message/HttpSyncMsg`
|
||||||
|
- 识别指令后调用:
|
||||||
|
- `POST /api/parse`
|
||||||
|
- `POST /api/generate`
|
||||||
|
- 将结果回发微信:
|
||||||
|
- 文本:`/message/SendTextMessage`
|
||||||
|
- 图片:`/message/SendImageNewMessage`
|
||||||
|
|
||||||
|
### 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/zc.workyai.cn/app
|
||||||
|
python3 wechat_bot_bridge.py \
|
||||||
|
--wechat-base-url http://127.0.0.1:18238 \
|
||||||
|
--wechat-session-file /root/WeChatPadPro_test_20260227/webui/.session.json \
|
||||||
|
--xibao-base-url https://zc.workyai.cn
|
||||||
|
```
|
||||||
|
|
||||||
|
可选参数:
|
||||||
|
|
||||||
|
- `--wechat-auth-key`:直接指定 authKey(不走 session 文件)
|
||||||
|
- `--allow-from wxid_a,wxid_b`:仅处理白名单发送者
|
||||||
|
- `--max-images 3`:每次最多回发几张图
|
||||||
|
- `--dry-run`:只打印,不实际回消息
|
||||||
|
- `--once`:只轮询一次(调试用)
|
||||||
|
|
||||||
|
### 微信指令
|
||||||
|
|
||||||
|
- `/喜报 帮助`
|
||||||
|
- `/喜报 解析 + 文本`
|
||||||
|
- `/喜报 生成 + 文本`
|
||||||
|
- `跳过N`(例如 `跳过3` / `跳过(3)`,当天自动跳过前 N 条序号内容)
|
||||||
|
- `跳过0`(取消当天自动跳过)
|
||||||
|
- `反馈+序号+说明`(记录到复盘/问题列表)
|
||||||
|
- 直接发送包含 `#接龙` 的文本也会触发生成
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 仅处理私聊文本消息(不处理群聊)。
|
||||||
|
- 跳过/屏蔽项不单独发送通知,识别与过滤完全使用本项目现有逻辑。
|
||||||
|
- 每日按 `--daily-cleanup-time` 自动清空输出图片与历史记录(不会清空反馈记录)。
|
||||||
|
|
||||||
|
## 常用排查命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 服务是否存活
|
||||||
|
systemctl status xibao-web.service
|
||||||
|
ss -lntp | rg ':8787'
|
||||||
|
|
||||||
|
# 2) 配置是否正常
|
||||||
|
curl -sS http://127.0.0.1:8787/api/config
|
||||||
|
|
||||||
|
# 3) 单条解析复现
|
||||||
|
curl -sS -X POST http://127.0.0.1:8787/api/parse \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"raw_text":"2、 蚣坝揽收十万现金定期一年"}'
|
||||||
|
|
||||||
|
# 4) 查看复盘日志(识别错/生成错/skip/异常)
|
||||||
|
tail -n 200 /opt/xibao-web/app/data/review_logs/review_$(date +%F).jsonl
|
||||||
|
|
||||||
|
# 5) 查看活跃标识
|
||||||
|
curl -sS 'http://127.0.0.1:8787/api/issues?status=active&limit=200'
|
||||||
|
|
||||||
|
# 6) 查看手工规则(是否有误命中)
|
||||||
|
cat /opt/xibao-web/app/data/manual_rules.json
|
||||||
|
|
||||||
|
# 7) 查看历史去重(为什么没新生成)
|
||||||
|
cat /opt/xibao-web/app/data/generated_history.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除指南
|
||||||
|
|
||||||
|
### 问题 1:识别正确,但图片里网点不对(例如固定变成“潇水南路”)
|
||||||
|
|
||||||
|
- 先确认 `api/parse` 输出的 `branch` 是否正确。
|
||||||
|
- 若解析正确但图错,重点检查模板页网点文本是否包含“营业所”字样。
|
||||||
|
- 当前替换逻辑按“营业所”文本段定位;模板若不含该标记,可能回退到配置索引。
|
||||||
|
- 模板调整后建议先用一条数据做小流量验证。
|
||||||
|
|
||||||
|
### 问题 2:`amount_not_found`
|
||||||
|
|
||||||
|
- 先用 `api/parse` 复现并看 `skipped` 原因。
|
||||||
|
- 已支持常见中文金额:`十万`、`两万五`、`一万二`、`二点五万`。
|
||||||
|
- 若仍失败,先看原文是否存在金额信息,再检查是否被特殊格式拆断。
|
||||||
|
|
||||||
|
### 问题 3:点“生成”后没有新图
|
||||||
|
|
||||||
|
- 查看返回里的 `duplicate_records` 和 `summary.duplicate`。
|
||||||
|
- 常见原因是去重命中(有序号时按序号键,无序号时按 `branch+amount+type`)。
|
||||||
|
- 需要重生成可清历史后再生成:`POST /api/history/clear`。
|
||||||
|
|
||||||
|
### 问题 4:日志里出现 `demand_deposit_not_generate`
|
||||||
|
|
||||||
|
- 这是预期策略:只出现活期类关键词且未写明确期限时默认不生成。
|
||||||
|
- 解决方式是补上产品期限或类型(例如“存一年”“理财”“保险5年交”)。
|
||||||
|
|
||||||
|
### 问题 5:转换失败(Docker 镜像拉取/转换异常)
|
||||||
|
|
||||||
|
- 先执行 `docker info` 确认 Docker 可用。
|
||||||
|
- 执行 `docker pull minidocks/libreoffice` 预热镜像。
|
||||||
|
- 查看服务日志:`journalctl -u xibao-web.service -n 300 --no-pager`。
|
||||||
|
|
||||||
|
### 问题 6:需要回溯某次“识别错/生成错”
|
||||||
|
|
||||||
|
- 进入 `data/review_logs/review_YYYY-MM-DD.jsonl` 查 `manual_mark`。
|
||||||
|
- 字段 `mark_type` 区分识别错/生成错,`source_line` 是原句,`note` 是备注。
|
||||||
|
- 结合同文件内的 `parse_skip`、`manual_correction_apply` 可还原完整过程。
|
||||||
|
|
||||||
|
### 问题 7:标识想改备注或删掉
|
||||||
|
|
||||||
|
- 页面“已标识错误”面板可直接编辑或删除。
|
||||||
|
- 或调用接口:
|
||||||
|
- `POST /api/issues/update`(按 `id` 修改)
|
||||||
|
- `POST /api/issues/delete`(按 `id` 删除)
|
||||||
|
|
||||||
|
## /api/generate 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"raw_text": "#接龙\n1、营江路张三30万存一年\n2、潇水南路保险2万",
|
||||||
|
"insurance_year": "5",
|
||||||
|
"template_file": "/opt/xibao-web/templates/黄金三十天喜报模版(余额、保险、理财)(1).pptx",
|
||||||
|
"output_dir": "/opt/xibao-web/app/output",
|
||||||
|
"save_history": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 需要本机 Docker 可用(`docker info` 正常)。
|
||||||
|
- 当前下载 token 默认 1 小时过期。
|
||||||
199
app/api_get_routes.py
Normal file
199
app/api_get_routes.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
from http import HTTPStatus
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
|
||||||
|
def handle_get_routes(handler: Any, parsed: Any, ctx: dict[str, Any]) -> bool:
|
||||||
|
load_config = ctx["load_config"]
|
||||||
|
resolve_template_path = ctx["resolve_template_path"]
|
||||||
|
resolve_output_dir = ctx["resolve_output_dir"]
|
||||||
|
resolve_history_path = ctx["resolve_history_path"]
|
||||||
|
today_log_path = ctx["today_log_path"]
|
||||||
|
list_issue_marks = ctx["list_issue_marks"]
|
||||||
|
count_log_lines = ctx["count_log_lines"]
|
||||||
|
load_history = ctx["load_history"]
|
||||||
|
build_history_view_items = ctx["build_history_view_items"]
|
||||||
|
get_generation_progress = ctx["get_generation_progress"]
|
||||||
|
resolve_image_delivery_options = ctx["resolve_image_delivery_options"]
|
||||||
|
ISSUE_MARKS_PATH = ctx["ISSUE_MARKS_PATH"]
|
||||||
|
_HISTORY_LOCK = ctx["_HISTORY_LOCK"]
|
||||||
|
_LOG_LOCK = ctx["_LOG_LOCK"]
|
||||||
|
_DOWNLOAD_LOCK = ctx["_DOWNLOAD_LOCK"]
|
||||||
|
DOWNLOAD_CACHE = ctx["DOWNLOAD_CACHE"]
|
||||||
|
|
||||||
|
if parsed.path == "/api/config":
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
template = resolve_template_path(config)
|
||||||
|
output = resolve_output_dir(config)
|
||||||
|
except Exception as exc:
|
||||||
|
handler._send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
return True
|
||||||
|
|
||||||
|
history_path = resolve_history_path(config)
|
||||||
|
log_path = today_log_path()
|
||||||
|
with _HISTORY_LOCK:
|
||||||
|
history = load_history(history_path)
|
||||||
|
_, active_issue_count = list_issue_marks(status="active", limit=1)
|
||||||
|
|
||||||
|
handler._send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"config": {
|
||||||
|
"template_file": str(template),
|
||||||
|
"output_dir": str(output),
|
||||||
|
"trigger_keyword": config.get("relay_handling", {}).get("trigger_keyword", "#接龙"),
|
||||||
|
"allowed_branches": config.get("branches", {}).get("allowed", []),
|
||||||
|
"type_matching": config.get("type_matching", {}),
|
||||||
|
"status_fallback": config.get("status_extraction", {}).get("fallback", "成功营销"),
|
||||||
|
"image_delivery": resolve_image_delivery_options(config),
|
||||||
|
},
|
||||||
|
"resolved_history_file": str(history_path),
|
||||||
|
"history_count": len(history),
|
||||||
|
"review_log_file": str(log_path),
|
||||||
|
"review_log_count": count_log_lines(log_path),
|
||||||
|
"issue_marks_file": str(ISSUE_MARKS_PATH),
|
||||||
|
"active_issue_count": active_issue_count,
|
||||||
|
"runtime_note": (
|
||||||
|
"当前版本支持:多条解析、保险年限选择、生成PPT并按页导出PNG、逐图下载。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/log/today":
|
||||||
|
path = today_log_path()
|
||||||
|
logs: list[dict[str, Any]] = []
|
||||||
|
if path.exists():
|
||||||
|
with _LOG_LOCK:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
s = line.strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(s)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
logs.append(obj)
|
||||||
|
handler._send_json({"ok": True, "log_file": str(path), "count": len(logs), "logs": logs[-500:]})
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/issues":
|
||||||
|
try:
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
status = str(params.get("status", ["active"])[0]).strip().lower()
|
||||||
|
try:
|
||||||
|
limit = int(params.get("limit", ["500"])[0])
|
||||||
|
except Exception:
|
||||||
|
limit = 500
|
||||||
|
limit = max(1, min(2000, limit))
|
||||||
|
items, total = list_issue_marks(status=status, limit=limit)
|
||||||
|
except Exception as exc:
|
||||||
|
handler._send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
return True
|
||||||
|
|
||||||
|
handler._send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"status": status if status in {"active", "resolved", "all"} else "active",
|
||||||
|
"count": total,
|
||||||
|
"limit": limit,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path.startswith("/api/progress/"):
|
||||||
|
token = parsed.path.split("/api/progress/", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
handler._send_json({"ok": False, "error": "progress token missing"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return True
|
||||||
|
item = get_generation_progress(token)
|
||||||
|
if item is None:
|
||||||
|
handler._send_json({"ok": False, "error": "progress token not found"}, HTTPStatus.NOT_FOUND)
|
||||||
|
return True
|
||||||
|
handler._send_json({"ok": True, "progress": item})
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/history":
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
history_path = resolve_history_path(config)
|
||||||
|
with _HISTORY_LOCK:
|
||||||
|
history = load_history(history_path)
|
||||||
|
except Exception as exc:
|
||||||
|
handler._send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
return True
|
||||||
|
|
||||||
|
handler._send_json({"ok": True, "count": len(history), "history": history})
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/history/view":
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
history_path = resolve_history_path(config)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
try:
|
||||||
|
limit = int(params.get("limit", ["300"])[0])
|
||||||
|
except Exception:
|
||||||
|
limit = 300
|
||||||
|
limit = max(1, min(2000, limit))
|
||||||
|
with _HISTORY_LOCK:
|
||||||
|
history = load_history(history_path)
|
||||||
|
items = build_history_view_items(history, config, limit=limit)
|
||||||
|
except Exception as exc:
|
||||||
|
handler._send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
return True
|
||||||
|
|
||||||
|
handler._send_json({"ok": True, "count": len(history), "limit": limit, "items": items})
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path.startswith("/api/download/"):
|
||||||
|
token = parsed.path.split("/api/download/", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
handler._send_json({"ok": False, "error": "download token missing"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return True
|
||||||
|
|
||||||
|
with _DOWNLOAD_LOCK:
|
||||||
|
meta = DOWNLOAD_CACHE.get(token)
|
||||||
|
|
||||||
|
if not meta:
|
||||||
|
handler._send_json({"ok": False, "error": "download expired or invalid"}, HTTPStatus.NOT_FOUND)
|
||||||
|
return True
|
||||||
|
|
||||||
|
file_path = Path(str(meta.get("file_path", "")))
|
||||||
|
if not file_path.exists():
|
||||||
|
handler._send_json({"ok": False, "error": "file missing"}, HTTPStatus.NOT_FOUND)
|
||||||
|
return True
|
||||||
|
|
||||||
|
content_type = str(meta.get("content_type", "")).strip()
|
||||||
|
if not content_type:
|
||||||
|
content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
||||||
|
filename = str(meta.get("filename", file_path.name))
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
inline = str(params.get("inline", ["0"])[0]).lower() in {"1", "true", "yes"}
|
||||||
|
rate_opts = {"max_kbps": 300, "chunk_size": 16 * 1024}
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
cfg_opts = resolve_image_delivery_options(config)
|
||||||
|
rate_opts["max_kbps"] = int(cfg_opts.get("max_kbps", 300))
|
||||||
|
rate_opts["chunk_size"] = int(cfg_opts.get("chunk_size", 16 * 1024))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
handler._send_file(
|
||||||
|
file_path,
|
||||||
|
content_type,
|
||||||
|
filename=None if inline else filename,
|
||||||
|
max_kbps=rate_opts["max_kbps"],
|
||||||
|
chunk_size=rate_opts["chunk_size"],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
115
app/api_post_routes.py
Normal file
115
app/api_post_routes.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from services.post_ops import (
|
||||||
|
run_delete_issue,
|
||||||
|
run_history_append,
|
||||||
|
run_history_clear,
|
||||||
|
run_mark_issue,
|
||||||
|
run_output_clear,
|
||||||
|
run_suppress_skipped,
|
||||||
|
run_update_issue,
|
||||||
|
)
|
||||||
|
from services.workflows import (
|
||||||
|
run_correction_apply_api,
|
||||||
|
run_generate_api,
|
||||||
|
run_parse_api,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_payload(handler: Any) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
payload = handler._read_json_body()
|
||||||
|
except ValueError as exc:
|
||||||
|
handler._send_json({"ok": False, "error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
handler._send_json({"ok": False, "error": "invalid JSON body"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def handle_post_routes(handler: Any, parsed: Any, ctx: dict[str, Any]) -> bool:
|
||||||
|
if parsed.path == "/api/parse":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_parse_api(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/generate":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_generate_api(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/correction/apply":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_correction_apply_api(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/log/mark":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_mark_issue(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/skipped/suppress":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_suppress_skipped(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/issues/update":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_update_issue(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/issues/delete":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_delete_issue(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/output/clear":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_output_clear(ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/history/append":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_history_append(payload, ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.path == "/api/history/clear":
|
||||||
|
payload = _read_payload(handler)
|
||||||
|
if payload is None:
|
||||||
|
return True
|
||||||
|
status, body = run_history_clear(ctx)
|
||||||
|
handler._send_json(body, status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
1196
app/app.js
Normal file
1196
app/app.js
Normal file
File diff suppressed because it is too large
Load Diff
392
app/config/xibao_logic.json
Normal file
392
app/config/xibao_logic.json
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
{
|
||||||
|
"template_file": "/opt/xibao-web/templates/黄金三十天喜报模版(余额、保险、理财)(1).pptx",
|
||||||
|
"output_dir": "/opt/xibao-web/app/output",
|
||||||
|
"history_file": "/opt/xibao-web/app/data/generated_history.json",
|
||||||
|
"output_settings": {
|
||||||
|
"auto_export_png": true,
|
||||||
|
"auto_cleanup_pptx": true,
|
||||||
|
"output_pattern": "喜报_{branch}_{index}.png"
|
||||||
|
},
|
||||||
|
"relay_handling": {
|
||||||
|
"enabled": true,
|
||||||
|
"trigger_keyword": "#接龙",
|
||||||
|
"parse_rules": {
|
||||||
|
"skip_lines": [
|
||||||
|
"业绩亮单英雄榜",
|
||||||
|
"例",
|
||||||
|
"保险、余额、基金",
|
||||||
|
"网点+姓名"
|
||||||
|
],
|
||||||
|
"line_pattern": "^\\d+、\\s*"
|
||||||
|
},
|
||||||
|
"dedup": {
|
||||||
|
"enabled": true,
|
||||||
|
"key_fields": [
|
||||||
|
"branch",
|
||||||
|
"amount",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"action_on_new": "delete_old_then_generate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"branches": {
|
||||||
|
"allowed": [
|
||||||
|
"四马桥",
|
||||||
|
"仙子脚",
|
||||||
|
"桥头",
|
||||||
|
"鸿雁",
|
||||||
|
"营江路",
|
||||||
|
"柑子园",
|
||||||
|
"潇水南路",
|
||||||
|
"濂溪",
|
||||||
|
"清塘",
|
||||||
|
"祥霖铺",
|
||||||
|
"梅花",
|
||||||
|
"蚣坝",
|
||||||
|
"白马渡"
|
||||||
|
],
|
||||||
|
"alias": {
|
||||||
|
"中心所": "潇水南路",
|
||||||
|
"营江路所": "营江路"
|
||||||
|
},
|
||||||
|
"template_location": {
|
||||||
|
"shape_index": 3,
|
||||||
|
"paragraph_index": 1,
|
||||||
|
"display_format": "{branch}营业所",
|
||||||
|
"company_replacement": "道县"
|
||||||
|
},
|
||||||
|
"shapes": {
|
||||||
|
"四马桥": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"仙子脚": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"桥头": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"鸿雁": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"营江路": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"柑子园": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"潇水南路": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"濂溪": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"清塘": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"祥霖铺": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"梅花": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"蚣坝": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
},
|
||||||
|
"白马渡": {
|
||||||
|
"shape": 0,
|
||||||
|
"paragraphs": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"page_0": {
|
||||||
|
"display_name": "三个月定期",
|
||||||
|
"amount_shape": 2,
|
||||||
|
"status_shape": 3,
|
||||||
|
"has_bracket": true
|
||||||
|
},
|
||||||
|
"page_1": {
|
||||||
|
"display_name": "六个月定期",
|
||||||
|
"amount_shape": 2,
|
||||||
|
"status_shape": 3,
|
||||||
|
"has_bracket": true
|
||||||
|
},
|
||||||
|
"page_2": {
|
||||||
|
"display_name": "一年期定期",
|
||||||
|
"amount_shape": 1,
|
||||||
|
"status_shape": 2,
|
||||||
|
"has_bracket": true
|
||||||
|
},
|
||||||
|
"page_3": {
|
||||||
|
"display_name": "期交保险",
|
||||||
|
"amount_shape": 2,
|
||||||
|
"status_shape": 3,
|
||||||
|
"has_bracket": false
|
||||||
|
},
|
||||||
|
"page_4": {
|
||||||
|
"display_name": "趸交保险",
|
||||||
|
"amount_shape": 2,
|
||||||
|
"status_shape": 3,
|
||||||
|
"has_bracket": false
|
||||||
|
},
|
||||||
|
"page_5": {
|
||||||
|
"display_name": "两三年期理财",
|
||||||
|
"display_aliases": [
|
||||||
|
"两三期理财",
|
||||||
|
"两三年期理财"
|
||||||
|
],
|
||||||
|
"amount_shape": 2,
|
||||||
|
"status_shape": 3,
|
||||||
|
"has_bracket": false
|
||||||
|
},
|
||||||
|
"page_6": {
|
||||||
|
"display_name": "资管",
|
||||||
|
"amount_shape": 2,
|
||||||
|
"status_shape": 3,
|
||||||
|
"has_bracket": false
|
||||||
|
},
|
||||||
|
"page_7": {
|
||||||
|
"display_name": "基金/理财",
|
||||||
|
"amount_shape": 2,
|
||||||
|
"status_shape": 3,
|
||||||
|
"has_bracket": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type_matching": {
|
||||||
|
"三个月定期": "page_0",
|
||||||
|
"六个月定期": "page_1",
|
||||||
|
"一年期定期": "page_2",
|
||||||
|
"一年": "page_2",
|
||||||
|
"定期一年": "page_2",
|
||||||
|
"定期": "page_2",
|
||||||
|
"5年交": "page_3",
|
||||||
|
"5年期交": "page_3",
|
||||||
|
"期交": "page_3",
|
||||||
|
"趸交": "page_4",
|
||||||
|
"两三期理财": "page_5",
|
||||||
|
"两三年期理财": "page_5",
|
||||||
|
"理财": "page_5",
|
||||||
|
"资管": "page_6",
|
||||||
|
"基金": "page_7",
|
||||||
|
"保险": "page_3"
|
||||||
|
},
|
||||||
|
"insurance_handling": {
|
||||||
|
"default": "期交",
|
||||||
|
"page": "page_3",
|
||||||
|
"description": "保险默认匹配期交(page_3),如需确认是几年交请手动修正"
|
||||||
|
},
|
||||||
|
"status_extraction": {
|
||||||
|
"description": "从原始语句中提取状态关键词,匹配4字状态词",
|
||||||
|
"valid_status": [
|
||||||
|
"揽收现金",
|
||||||
|
"微信提现",
|
||||||
|
"揽收他行",
|
||||||
|
"他行挖转",
|
||||||
|
"存量提升",
|
||||||
|
"商户提现",
|
||||||
|
"揽收商户",
|
||||||
|
"揽收彩礼",
|
||||||
|
"资金来源",
|
||||||
|
"成功营销"
|
||||||
|
],
|
||||||
|
"extraction_rules": {
|
||||||
|
"揽收他行": [
|
||||||
|
"揽收他行",
|
||||||
|
"他行揽收"
|
||||||
|
],
|
||||||
|
"他行挖转": [
|
||||||
|
"他行挖转",
|
||||||
|
"挖转",
|
||||||
|
"挖他行"
|
||||||
|
],
|
||||||
|
"揽收现金": [
|
||||||
|
"揽收现金"
|
||||||
|
],
|
||||||
|
"微信提现": [
|
||||||
|
"微信提现"
|
||||||
|
],
|
||||||
|
"商户提现": [
|
||||||
|
"商户提现"
|
||||||
|
],
|
||||||
|
"揽收商户": [
|
||||||
|
"揽收商户"
|
||||||
|
],
|
||||||
|
"揽收彩礼": [
|
||||||
|
"揽收彩礼",
|
||||||
|
"礼金"
|
||||||
|
],
|
||||||
|
"存量提升": [
|
||||||
|
"存量提升"
|
||||||
|
],
|
||||||
|
"资金来源": [
|
||||||
|
"资金来源"
|
||||||
|
],
|
||||||
|
"成功营销": [
|
||||||
|
"成功营销"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fallback": "成功营销",
|
||||||
|
"priority_order": [
|
||||||
|
"揽收现金",
|
||||||
|
"微信提现",
|
||||||
|
"揽收他行",
|
||||||
|
"他行挖转",
|
||||||
|
"揽收彩礼",
|
||||||
|
"商户提现",
|
||||||
|
"揽收商户",
|
||||||
|
"存量提升",
|
||||||
|
"资金来源",
|
||||||
|
"成功营销"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"replace_algorithm": {
|
||||||
|
"important": "必须遍历runs逐个处理,保持字体格式不改变",
|
||||||
|
"amount": {
|
||||||
|
"step1": "遍历所有shapes的text_frame",
|
||||||
|
"step2": "遍历每个paragraph的runs",
|
||||||
|
"step3": "判断条件:run.text.strip()是纯数字或纯*",
|
||||||
|
"step4": "只替换该run的text为新金额数字(去掉万)",
|
||||||
|
"step5": "其他runs(文字、单位的万等)保持不变",
|
||||||
|
"condition_digit": "text.strip().isdigit()",
|
||||||
|
"condition_star": "text.strip() == '*'",
|
||||||
|
"example": {
|
||||||
|
"原始runs": [
|
||||||
|
"一年期定期",
|
||||||
|
"14",
|
||||||
|
"万"
|
||||||
|
],
|
||||||
|
"新金额": "30万",
|
||||||
|
"替换后runs": [
|
||||||
|
"一年期定期",
|
||||||
|
"30",
|
||||||
|
"万"
|
||||||
|
],
|
||||||
|
"最终显示": "一年期定期30万"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "根据page和shape索引替换状态",
|
||||||
|
"page_2_status_shape": 2,
|
||||||
|
"page_3_status_shape": 3,
|
||||||
|
"rule": "只替换长度为4的中文字符串(状态词)",
|
||||||
|
"condition": "len(text.strip()) == 4",
|
||||||
|
"valid_status": [
|
||||||
|
"揽收现金",
|
||||||
|
"微信提现",
|
||||||
|
"揽收他行",
|
||||||
|
"他行挖转",
|
||||||
|
"存量提升",
|
||||||
|
"商户提现",
|
||||||
|
"揽收商户",
|
||||||
|
"揽收彩礼",
|
||||||
|
"资金来源",
|
||||||
|
"成功营销"
|
||||||
|
],
|
||||||
|
"note": "不能替换类型关键词如'期交'、'定期'等",
|
||||||
|
"example_with_bracket": {
|
||||||
|
"原始runs": [
|
||||||
|
"(",
|
||||||
|
"成功营销",
|
||||||
|
")"
|
||||||
|
],
|
||||||
|
"新状态": "他行挖转",
|
||||||
|
"替换后runs": [
|
||||||
|
"(",
|
||||||
|
"他行挖转",
|
||||||
|
")"
|
||||||
|
],
|
||||||
|
"最终显示": "(他行挖转)"
|
||||||
|
},
|
||||||
|
"example_without_bracket": {
|
||||||
|
"原始runs": [
|
||||||
|
"成功营销"
|
||||||
|
],
|
||||||
|
"新状态": "他行挖转",
|
||||||
|
"替换后runs": [
|
||||||
|
"他行挖转"
|
||||||
|
],
|
||||||
|
"最终显示": "他行挖转"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"description": "动态替换网点名称",
|
||||||
|
"shape_index": 3,
|
||||||
|
"paragraph_index": 1,
|
||||||
|
"format": "{branch}营业所",
|
||||||
|
"company_replacement": "道县",
|
||||||
|
"note": "替换Shape 3 Paragraph 1的文本为'网点名称+营业所'",
|
||||||
|
"example": {
|
||||||
|
"原始": "仙子脚营业所",
|
||||||
|
"新网点": "白马渡",
|
||||||
|
"替换后": "白马渡营业所"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"single_generation_mode": true,
|
||||||
|
"max_build_workers": 1,
|
||||||
|
"max_extract_workers": 1,
|
||||||
|
"single_slide_output": true,
|
||||||
|
"generation_strategy": "page_template_cache",
|
||||||
|
"template_cache_min_records": 2,
|
||||||
|
"memory_reclaim": {
|
||||||
|
"enabled": true,
|
||||||
|
"gc_collect": true,
|
||||||
|
"malloc_trim": true
|
||||||
|
},
|
||||||
|
"image_delivery": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_kbps": 0,
|
||||||
|
"chunk_kb": 64
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workflow": {
|
||||||
|
"step_1": "检测消息是否包含#接龙关键词",
|
||||||
|
"step_1a": "是接龙:解析每行数据,提取网点+金额+类型+状态",
|
||||||
|
"step_1b": "读取history_file获取已生成的数据记录",
|
||||||
|
"step_1c": "对比新旧数据,找出新增的记录",
|
||||||
|
"step_1d": "如果有待删除的旧记录,删除对应的PNG文件",
|
||||||
|
"step_1e": "只对新记录执行生成",
|
||||||
|
"step_2": "遍历输入数据列表",
|
||||||
|
"step_2a": "检查branch是否在allowed_branches中,如不在则跳过",
|
||||||
|
"step_2b": "检查alias,将别名转换为潇水南路",
|
||||||
|
"step_3": "从数据中提取:branch(网点)、amount(金额)、type(类型)",
|
||||||
|
"step_3a": "使用status_extraction从原始语句中提取状态关键词",
|
||||||
|
"step_4": "用type字段从type_matching找到对应page",
|
||||||
|
"step_5": "每条数据重新加载模板(避免状态累积)",
|
||||||
|
"step_6": "调用replace_algorithm.branch修改网点名称",
|
||||||
|
"step_7": "调用replace_algorithm.amount修改金额(只改数字run)",
|
||||||
|
"step_8": "调用replace_algorithm.status修改状态(只改4字状态词)",
|
||||||
|
"step_9": "保存临时PPT,使用PowerPoint导出PNG图片",
|
||||||
|
"step_10": "自动清理临时PPT文件,只保留PNG图片",
|
||||||
|
"step_11": "更新history_file记录已生成的数据",
|
||||||
|
"step_12": "重复步骤2-11直到处理完所有新数据"
|
||||||
|
},
|
||||||
|
"data_format": {
|
||||||
|
"required_fields": [
|
||||||
|
"raw_text"
|
||||||
|
],
|
||||||
|
"parsed_fields": [
|
||||||
|
"branch",
|
||||||
|
"amount",
|
||||||
|
"type",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"branch_filter": {
|
||||||
|
"description": "只允许13个网点,不是则跳过该条数据",
|
||||||
|
"action": "skip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
370
app/package-lock.json
generated
Normal file
370
app/package-lock.json
generated
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
{
|
||||||
|
"name": "xibao-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "xibao-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"vue": "3.5.29"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.28.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/parser": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.29.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"parser": "bin/babel-parser.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/types": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-core": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.0",
|
||||||
|
"@vue/shared": "3.5.29",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-dom": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-core": "3.5.29",
|
||||||
|
"@vue/shared": "3.5.29"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-sfc": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.0",
|
||||||
|
"@vue/compiler-core": "3.5.29",
|
||||||
|
"@vue/compiler-dom": "3.5.29",
|
||||||
|
"@vue/compiler-ssr": "3.5.29",
|
||||||
|
"@vue/shared": "3.5.29",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-ssr": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-dom": "3.5.29",
|
||||||
|
"@vue/shared": "3.5.29"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/reactivity": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/shared": "3.5.29"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/runtime-core": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/reactivity": "3.5.29",
|
||||||
|
"@vue/shared": "3.5.29"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/runtime-dom": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/reactivity": "3.5.29",
|
||||||
|
"@vue/runtime-core": "3.5.29",
|
||||||
|
"@vue/shared": "3.5.29",
|
||||||
|
"csstype": "^3.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/server-renderer": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-ssr": "3.5.29",
|
||||||
|
"@vue/shared": "3.5.29"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "3.5.29"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/shared": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.11",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue": {
|
||||||
|
"version": "3.5.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||||
|
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-dom": "3.5.29",
|
||||||
|
"@vue/compiler-sfc": "3.5.29",
|
||||||
|
"@vue/runtime-dom": "3.5.29",
|
||||||
|
"@vue/server-renderer": "3.5.29",
|
||||||
|
"@vue/shared": "3.5.29"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/package.json
Normal file
16
app/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "xibao-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Xibao web tooling scripts",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "node --check static/app.js && node --check static/js/core/state.js && node --check static/js/main.js",
|
||||||
|
"test": "python3 -m unittest discover -s tests -v",
|
||||||
|
"smoke": "bash scripts/smoke_api.sh",
|
||||||
|
"sync:vue": "bash scripts/sync_vue_vendor.sh"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"vue": "3.5.29"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
app/repositories/__init__.py
Normal file
0
app/repositories/__init__.py
Normal file
58
app/repositories/history_repository.py
Normal file
58
app/repositories/history_repository.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def load_history_for_config(config: dict[str, Any], ctx: dict[str, Any]) -> tuple[Any, list[dict[str, Any]]]:
|
||||||
|
resolve_history_path = ctx["resolve_history_path"]
|
||||||
|
load_history = ctx["load_history"]
|
||||||
|
history_lock = ctx["_HISTORY_LOCK"]
|
||||||
|
|
||||||
|
history_path = resolve_history_path(config)
|
||||||
|
with history_lock:
|
||||||
|
history = load_history(history_path)
|
||||||
|
return history_path, history
|
||||||
|
|
||||||
|
|
||||||
|
def append_generated_history(
|
||||||
|
history_path: Any,
|
||||||
|
generated_items: list[dict[str, Any]],
|
||||||
|
key_fields: list[str],
|
||||||
|
ctx: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
append_new_history = ctx["append_new_history"]
|
||||||
|
load_history = ctx["load_history"]
|
||||||
|
history_lock = ctx["_HISTORY_LOCK"]
|
||||||
|
|
||||||
|
with history_lock:
|
||||||
|
current_history = load_history(history_path)
|
||||||
|
items = generated_items if isinstance(generated_items, list) else []
|
||||||
|
return append_new_history(history_path, current_history, items, key_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_generated_history(
|
||||||
|
history_path: Any,
|
||||||
|
generated_items: list[dict[str, Any]],
|
||||||
|
key_fields: list[str],
|
||||||
|
ctx: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
upsert_history_records = ctx["upsert_history_records"]
|
||||||
|
load_history = ctx["load_history"]
|
||||||
|
history_lock = ctx["_HISTORY_LOCK"]
|
||||||
|
|
||||||
|
with history_lock:
|
||||||
|
current_history = load_history(history_path)
|
||||||
|
items = generated_items if isinstance(generated_items, list) else []
|
||||||
|
return upsert_history_records(history_path, current_history, items, key_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_history_and_suppressions(config: dict[str, Any], ctx: dict[str, Any]) -> int:
|
||||||
|
resolve_history_path = ctx["resolve_history_path"]
|
||||||
|
save_history = ctx["save_history"]
|
||||||
|
clear_skip_suppressions = ctx["clear_skip_suppressions"]
|
||||||
|
history_lock = ctx["_HISTORY_LOCK"]
|
||||||
|
|
||||||
|
history_path = resolve_history_path(config)
|
||||||
|
with history_lock:
|
||||||
|
save_history(history_path, [])
|
||||||
|
return int(clear_skip_suppressions())
|
||||||
1
app/requirements.txt
Normal file
1
app/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python-pptx==1.0.2
|
||||||
16
app/scripts/bootstrap.sh
Executable file
16
app/scripts/bootstrap.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
echo "[1/3] Installing Python dependencies..."
|
||||||
|
python3 -m pip install --user --break-system-packages -r requirements.txt
|
||||||
|
|
||||||
|
echo "[2/3] Pulling converter image..."
|
||||||
|
docker pull minidocks/libreoffice
|
||||||
|
|
||||||
|
echo "[3/3] Verifying converter image..."
|
||||||
|
docker image inspect minidocks/libreoffice >/dev/null
|
||||||
|
|
||||||
|
echo "Bootstrap complete. Converter image is ready."
|
||||||
28
app/scripts/smoke_api.sh
Executable file
28
app/scripts/smoke_api.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${1:-https://zc.workyai.cn}"
|
||||||
|
|
||||||
|
require_ok() {
|
||||||
|
local json="$1"
|
||||||
|
if ! printf '%s' "$json" | rg -q '"ok"\s*:\s*true'; then
|
||||||
|
printf 'Smoke failed: expected ok=true, got: %s\n' "$json" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
config_json="$(curl -fsS "$BASE_URL/api/config")"
|
||||||
|
require_ok "$config_json"
|
||||||
|
|
||||||
|
issues_json="$(curl -fsS "$BASE_URL/api/issues?status=active&limit=5")"
|
||||||
|
require_ok "$issues_json"
|
||||||
|
|
||||||
|
parse_json="$(curl -fsS -X POST "$BASE_URL/api/parse" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"raw_text":"#接龙\n1、营江路揽收现金10万存一年"}')"
|
||||||
|
require_ok "$parse_json"
|
||||||
|
|
||||||
|
history_json="$(curl -fsS "$BASE_URL/api/history/view?limit=5")"
|
||||||
|
require_ok "$history_json"
|
||||||
|
|
||||||
|
echo "Smoke passed: $BASE_URL"
|
||||||
17
app/scripts/sync_vue_vendor.sh
Executable file
17
app/scripts/sync_vue_vendor.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
VUE_SRC="$ROOT_DIR/node_modules/vue/dist/vue.global.prod.js"
|
||||||
|
VUE_DST_DIR="$ROOT_DIR/static/vendor"
|
||||||
|
VUE_DST="$VUE_DST_DIR/vue.global.prod.js"
|
||||||
|
|
||||||
|
if [[ ! -f "$VUE_SRC" ]]; then
|
||||||
|
echo "vue.global.prod.js not found. Run: npm install" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$VUE_DST_DIR"
|
||||||
|
cp "$VUE_SRC" "$VUE_DST"
|
||||||
|
|
||||||
|
echo "Synced Vue vendor: $VUE_DST"
|
||||||
3348
app/server.py
Normal file
3348
app/server.py
Normal file
File diff suppressed because it is too large
Load Diff
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
218
app/services/post_ops.py
Normal file
218
app/services/post_ops.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from repositories.history_repository import (
|
||||||
|
append_generated_history,
|
||||||
|
clear_history_and_suppressions,
|
||||||
|
load_history_for_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_mark_issue(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
upsert_issue_mark = ctx["upsert_issue_mark"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
try:
|
||||||
|
mark_type = str(payload.get("mark_type", "")).strip()
|
||||||
|
if mark_type not in {"recognition_error", "generation_error"}:
|
||||||
|
raise ValueError("mark_type must be recognition_error or generation_error")
|
||||||
|
|
||||||
|
source_line = str(payload.get("source_line", "")).strip()
|
||||||
|
if not source_line:
|
||||||
|
raise ValueError("source_line is required")
|
||||||
|
|
||||||
|
note = str(payload.get("note", "")).strip()
|
||||||
|
record = payload.get("record")
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
record = {}
|
||||||
|
issue, issue_created = upsert_issue_mark(
|
||||||
|
mark_type=mark_type,
|
||||||
|
source_line=source_line,
|
||||||
|
note=note,
|
||||||
|
record=record,
|
||||||
|
)
|
||||||
|
|
||||||
|
log_path = append_review_log(
|
||||||
|
"manual_mark",
|
||||||
|
{
|
||||||
|
"mark_type": mark_type,
|
||||||
|
"source_line": source_line,
|
||||||
|
"note": note,
|
||||||
|
"record": record,
|
||||||
|
"issue_id": str(issue.get("id", "")),
|
||||||
|
"issue_action": "create" if issue_created else "update",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return (
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": "已记录到复盘日志",
|
||||||
|
"log_file": str(log_path),
|
||||||
|
"issue": issue,
|
||||||
|
"issue_created": issue_created,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_suppress_skipped(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
suppress_skip_item = ctx["suppress_skip_item"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
try:
|
||||||
|
line = str(payload.get("line", "")).strip()
|
||||||
|
reason = str(payload.get("reason", "")).strip()
|
||||||
|
item, created = suppress_skip_item(line, reason)
|
||||||
|
append_review_log(
|
||||||
|
"skip_suppress",
|
||||||
|
{
|
||||||
|
"line": line,
|
||||||
|
"reason": reason or "*",
|
||||||
|
"created": created,
|
||||||
|
"suppress_id": str(item.get("id", "")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return HTTPStatus.OK, {"ok": True, "created": created, "item": item}
|
||||||
|
|
||||||
|
|
||||||
|
def run_update_issue(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
update_issue_mark = ctx["update_issue_mark"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
try:
|
||||||
|
issue_id = str(payload.get("id", "")).strip()
|
||||||
|
if not issue_id:
|
||||||
|
raise ValueError("id is required")
|
||||||
|
|
||||||
|
has_mark_type = "mark_type" in payload
|
||||||
|
has_source_line = "source_line" in payload
|
||||||
|
has_note = "note" in payload
|
||||||
|
has_record = "record" in payload
|
||||||
|
if not (has_mark_type or has_source_line or has_note or has_record):
|
||||||
|
raise ValueError("nothing to update")
|
||||||
|
|
||||||
|
issue = update_issue_mark(
|
||||||
|
issue_id=issue_id,
|
||||||
|
mark_type=payload.get("mark_type") if has_mark_type else None,
|
||||||
|
source_line=payload.get("source_line") if has_source_line else None,
|
||||||
|
note=payload.get("note") if has_note else None,
|
||||||
|
record=payload.get("record") if has_record and isinstance(payload.get("record"), dict) else None,
|
||||||
|
)
|
||||||
|
if issue is None:
|
||||||
|
return HTTPStatus.NOT_FOUND, {"ok": False, "error": "issue not found"}
|
||||||
|
append_review_log(
|
||||||
|
"issue_update",
|
||||||
|
{
|
||||||
|
"issue_id": issue_id,
|
||||||
|
"mark_type": issue.get("mark_type", ""),
|
||||||
|
"source_line": issue.get("source_line", ""),
|
||||||
|
"note": issue.get("note", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return HTTPStatus.OK, {"ok": True, "issue": issue}
|
||||||
|
|
||||||
|
|
||||||
|
def run_delete_issue(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
delete_issue_mark = ctx["delete_issue_mark"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
try:
|
||||||
|
issue_id = str(payload.get("id", "")).strip()
|
||||||
|
if not issue_id:
|
||||||
|
raise ValueError("id is required")
|
||||||
|
deleted = delete_issue_mark(issue_id)
|
||||||
|
if not deleted:
|
||||||
|
return HTTPStatus.NOT_FOUND, {"ok": False, "error": "issue not found"}
|
||||||
|
append_review_log(
|
||||||
|
"issue_delete",
|
||||||
|
{
|
||||||
|
"issue_id": issue_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return HTTPStatus.OK, {"ok": True, "id": issue_id}
|
||||||
|
|
||||||
|
|
||||||
|
def run_output_clear(ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
load_config = ctx["load_config"]
|
||||||
|
resolve_output_dir = ctx["resolve_output_dir"]
|
||||||
|
cleanup_output_artifacts = ctx["cleanup_output_artifacts"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
output_dir = resolve_output_dir(config)
|
||||||
|
stat = cleanup_output_artifacts(output_dir)
|
||||||
|
append_review_log(
|
||||||
|
"manual_cleanup",
|
||||||
|
{
|
||||||
|
"output_dir": str(output_dir),
|
||||||
|
**stat,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return (
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": "输出目录已清理",
|
||||||
|
"output_dir": str(output_dir),
|
||||||
|
**stat,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_history_append(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
load_config = ctx["load_config"]
|
||||||
|
try:
|
||||||
|
records = payload.get("records", [])
|
||||||
|
key_fields = payload.get("key_fields", ["branch", "amount", "type"])
|
||||||
|
if not isinstance(records, list):
|
||||||
|
raise ValueError("records must be a list")
|
||||||
|
if not isinstance(key_fields, list) or not key_fields:
|
||||||
|
key_fields = ["branch", "amount", "type"]
|
||||||
|
key_fields = [str(k) for k in key_fields]
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
history_path, _ = load_history_for_config(config, ctx)
|
||||||
|
stat = append_generated_history(
|
||||||
|
history_path=history_path,
|
||||||
|
generated_items=records,
|
||||||
|
key_fields=key_fields,
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return HTTPStatus.OK, {"ok": True, **stat}
|
||||||
|
|
||||||
|
|
||||||
|
def run_history_clear(ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
load_config = ctx["load_config"]
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
suppressed_cleared = clear_history_and_suppressions(config, ctx)
|
||||||
|
except Exception as exc:
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return HTTPStatus.OK, {"ok": True, "count": 0, "skipped_suppressed_cleared": suppressed_cleared}
|
||||||
448
app/services/workflows.py
Normal file
448
app/services/workflows.py
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from repositories.history_repository import (
|
||||||
|
append_generated_history,
|
||||||
|
load_history_for_config,
|
||||||
|
upsert_generated_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_key_fields(value: Any) -> list[str]:
|
||||||
|
key_fields = value if isinstance(value, list) else []
|
||||||
|
if not key_fields:
|
||||||
|
key_fields = ["branch", "amount", "type"]
|
||||||
|
return [str(x) for x in key_fields]
|
||||||
|
|
||||||
|
|
||||||
|
def run_parse_api(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
normalize_insurance_year = ctx["normalize_insurance_year"]
|
||||||
|
normalize_insurance_year_choices = ctx["normalize_insurance_year_choices"]
|
||||||
|
load_config = ctx["load_config"]
|
||||||
|
parse_records = ctx["parse_records"]
|
||||||
|
log_parse_skipped = ctx["log_parse_skipped"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
|
||||||
|
raw_text = str(payload.get("raw_text", ""))
|
||||||
|
try:
|
||||||
|
insurance_year_choice = normalize_insurance_year(payload.get("insurance_year"))
|
||||||
|
insurance_year_choices = normalize_insurance_year_choices(payload.get("insurance_year_choices"))
|
||||||
|
config = load_config()
|
||||||
|
_, history = load_history_for_config(config, ctx)
|
||||||
|
result = parse_records(raw_text, config, history, insurance_year_choice, insurance_year_choices)
|
||||||
|
log_parse_skipped(result.get("skipped", []), source="api_parse")
|
||||||
|
return HTTPStatus.OK, {"ok": True, "result": result}
|
||||||
|
except ValueError as exc:
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
append_review_log(
|
||||||
|
"parse_api_error",
|
||||||
|
{
|
||||||
|
"error": str(exc),
|
||||||
|
"raw_text": raw_text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_generate_api(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
normalize_insurance_year = ctx["normalize_insurance_year"]
|
||||||
|
normalize_insurance_year_choices = ctx["normalize_insurance_year_choices"]
|
||||||
|
load_config = ctx["load_config"]
|
||||||
|
resolve_template_path = ctx["resolve_template_path"]
|
||||||
|
resolve_output_dir = ctx["resolve_output_dir"]
|
||||||
|
parse_records = ctx["parse_records"]
|
||||||
|
generate_records = ctx["generate_records"]
|
||||||
|
set_generation_progress = ctx["set_generation_progress"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
log_parse_skipped = ctx["log_parse_skipped"]
|
||||||
|
resolve_single_generation_mode = ctx.get("resolve_single_generation_mode")
|
||||||
|
acquire_generation_slot = ctx.get("acquire_generation_slot")
|
||||||
|
release_generation_slot = ctx.get("release_generation_slot")
|
||||||
|
|
||||||
|
raw_text = str(payload.get("raw_text", ""))
|
||||||
|
progress_token = str(payload.get("progress_token", "")).strip() or uuid.uuid4().hex
|
||||||
|
slot_acquired = False
|
||||||
|
try:
|
||||||
|
template_override = str(payload.get("template_file", "")).strip() or None
|
||||||
|
output_override = str(payload.get("output_dir", "")).strip() or None
|
||||||
|
insurance_year_choice = normalize_insurance_year(payload.get("insurance_year"))
|
||||||
|
insurance_year_choices = normalize_insurance_year_choices(payload.get("insurance_year_choices"))
|
||||||
|
save_history_flag = bool(payload.get("save_history", True))
|
||||||
|
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="running",
|
||||||
|
stage="接收请求",
|
||||||
|
percent=1,
|
||||||
|
detail="已收到生成请求",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
single_mode = bool(resolve_single_generation_mode(config)) if callable(resolve_single_generation_mode) else True
|
||||||
|
if single_mode and callable(acquire_generation_slot):
|
||||||
|
slot_acquired = bool(acquire_generation_slot(progress_token))
|
||||||
|
if not slot_acquired:
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="busy",
|
||||||
|
stage="系统繁忙",
|
||||||
|
percent=0,
|
||||||
|
detail="已有任务在生成,请稍后重试",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
HTTPStatus.TOO_MANY_REQUESTS,
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "generate_busy",
|
||||||
|
"error_code": "generate_busy",
|
||||||
|
"message": "已有任务在生成,请稍后再试。",
|
||||||
|
"progress_token": progress_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
history_path, history = load_history_for_config(config, ctx)
|
||||||
|
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="running",
|
||||||
|
stage="解析文本",
|
||||||
|
percent=8,
|
||||||
|
detail="正在解析接龙内容",
|
||||||
|
)
|
||||||
|
parse_result = parse_records(
|
||||||
|
raw_text,
|
||||||
|
config,
|
||||||
|
history,
|
||||||
|
insurance_year_choice,
|
||||||
|
insurance_year_choices,
|
||||||
|
)
|
||||||
|
log_parse_skipped(parse_result.get("skipped", []), source="api_generate")
|
||||||
|
|
||||||
|
if parse_result.get("needs_insurance_choice") and insurance_year_choice is None:
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="need_input",
|
||||||
|
stage="等待选择",
|
||||||
|
percent=15,
|
||||||
|
detail="检测到保险记录,等待选择3年交/5年交",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "insurance_year_required",
|
||||||
|
"error_code": "insurance_year_required",
|
||||||
|
"result": parse_result,
|
||||||
|
"options": ["3", "5"],
|
||||||
|
"message": "检测到保险记录但未指定年限,请逐条选择3年交或5年交。",
|
||||||
|
"progress_token": progress_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
new_records = parse_result.get("new_records", [])
|
||||||
|
if not isinstance(new_records, list):
|
||||||
|
new_records = []
|
||||||
|
|
||||||
|
if not new_records:
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="done",
|
||||||
|
stage="完成",
|
||||||
|
percent=100,
|
||||||
|
detail="没有可生成的新记录",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": "没有可生成的新记录",
|
||||||
|
"result": parse_result,
|
||||||
|
"generated_count": 0,
|
||||||
|
"progress_token": progress_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="running",
|
||||||
|
stage="准备模板",
|
||||||
|
percent=12,
|
||||||
|
detail=f"待生成 {len(new_records)} 条",
|
||||||
|
)
|
||||||
|
template_path = resolve_template_path(config, template_override)
|
||||||
|
output_dir = resolve_output_dir(config, output_override)
|
||||||
|
|
||||||
|
def on_progress(percent: int, stage: str, detail: str) -> None:
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="running",
|
||||||
|
stage=stage,
|
||||||
|
percent=percent,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
gen_result = generate_records(
|
||||||
|
new_records,
|
||||||
|
config,
|
||||||
|
template_path,
|
||||||
|
output_dir,
|
||||||
|
progress_cb=on_progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
history_stat = None
|
||||||
|
if save_history_flag:
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="running",
|
||||||
|
stage="更新历史",
|
||||||
|
percent=96,
|
||||||
|
detail="写入历史记录",
|
||||||
|
)
|
||||||
|
key_fields = _normalize_key_fields(parse_result.get("dedup_key_fields", ["branch", "amount", "type"]))
|
||||||
|
history_stat = append_generated_history(
|
||||||
|
history_path=history_path,
|
||||||
|
generated_items=gen_result.get("generated", []),
|
||||||
|
key_fields=key_fields,
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="done",
|
||||||
|
stage="完成",
|
||||||
|
percent=100,
|
||||||
|
detail=f"已生成 {gen_result.get('generated_count', 0)} 张",
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": "生成完成",
|
||||||
|
"result": parse_result,
|
||||||
|
"generated_count": gen_result.get("generated_count", 0),
|
||||||
|
"generated": gen_result.get("generated", []),
|
||||||
|
"download_images": gen_result.get("download_images", []),
|
||||||
|
"generation_strategy": gen_result.get("generation_strategy", "legacy"),
|
||||||
|
"history": history_stat,
|
||||||
|
"progress_token": progress_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="error",
|
||||||
|
stage="失败",
|
||||||
|
percent=100,
|
||||||
|
detail="请求参数错误",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
append_review_log(
|
||||||
|
"generate_api_error",
|
||||||
|
{
|
||||||
|
"error": str(exc),
|
||||||
|
"raw_text": raw_text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
set_generation_progress(
|
||||||
|
progress_token,
|
||||||
|
status="error",
|
||||||
|
stage="失败",
|
||||||
|
percent=100,
|
||||||
|
detail="生成过程异常",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
finally:
|
||||||
|
if slot_acquired and callable(release_generation_slot):
|
||||||
|
release_generation_slot(progress_token)
|
||||||
|
|
||||||
|
|
||||||
|
def run_correction_apply_api(payload: dict[str, Any], ctx: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||||
|
load_config = ctx["load_config"]
|
||||||
|
resolve_template_path = ctx["resolve_template_path"]
|
||||||
|
resolve_output_dir = ctx["resolve_output_dir"]
|
||||||
|
resolve_history_path = ctx["resolve_history_path"]
|
||||||
|
normalize_line = ctx["normalize_line"]
|
||||||
|
normalize_branch_value = ctx["normalize_branch_value"]
|
||||||
|
normalize_amount_text = ctx["normalize_amount_text"]
|
||||||
|
normalize_status_value = ctx["normalize_status_value"]
|
||||||
|
infer_page_from_type = ctx["infer_page_from_type"]
|
||||||
|
apply_record_overrides = ctx["apply_record_overrides"]
|
||||||
|
render_output_filename = ctx["render_output_filename"]
|
||||||
|
validate_record_for_generation = ctx["validate_record_for_generation"]
|
||||||
|
generate_records = ctx["generate_records"]
|
||||||
|
infer_correction_rule_keyword = ctx["infer_correction_rule_keyword"]
|
||||||
|
save_or_update_manual_rule = ctx["save_or_update_manual_rule"]
|
||||||
|
append_review_log = ctx["append_review_log"]
|
||||||
|
resolve_issue_marks_by_source_line = ctx["resolve_issue_marks_by_source_line"]
|
||||||
|
resolve_single_generation_mode = ctx.get("resolve_single_generation_mode")
|
||||||
|
acquire_generation_slot = ctx.get("acquire_generation_slot")
|
||||||
|
release_generation_slot = ctx.get("release_generation_slot")
|
||||||
|
|
||||||
|
issue_resolve_stat: dict[str, Any] = {"count": 0, "ids": []}
|
||||||
|
slot_acquired = False
|
||||||
|
try:
|
||||||
|
record = payload.get("record")
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
raise ValueError("record is required")
|
||||||
|
overrides = payload.get("overrides", {})
|
||||||
|
if overrides is None:
|
||||||
|
overrides = {}
|
||||||
|
if not isinstance(overrides, dict):
|
||||||
|
raise ValueError("overrides must be an object")
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
single_mode = bool(resolve_single_generation_mode(config)) if callable(resolve_single_generation_mode) else True
|
||||||
|
if single_mode and callable(acquire_generation_slot):
|
||||||
|
slot_acquired = bool(acquire_generation_slot("correction_apply"))
|
||||||
|
if not slot_acquired:
|
||||||
|
return (
|
||||||
|
HTTPStatus.TOO_MANY_REQUESTS,
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "generate_busy",
|
||||||
|
"error_code": "generate_busy",
|
||||||
|
"message": "已有任务在生成,请稍后再试。",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
template_override = str(payload.get("template_file", "")).strip() or None
|
||||||
|
output_override = str(payload.get("output_dir", "")).strip() or None
|
||||||
|
template_path = resolve_template_path(config, template_override)
|
||||||
|
output_dir = resolve_output_dir(config, output_override)
|
||||||
|
history_path = resolve_history_path(config)
|
||||||
|
|
||||||
|
relay_cfg = config.get("relay_handling", {})
|
||||||
|
parse_rules = relay_cfg.get("parse_rules", {}) if isinstance(relay_cfg, dict) else {}
|
||||||
|
line_pattern = str(parse_rules.get("line_pattern", r"^\d+、\s*"))
|
||||||
|
|
||||||
|
source_line = str(record.get("source_line", "")).strip()
|
||||||
|
raw_text = str(record.get("raw_text", "")).strip()
|
||||||
|
normalized_line = normalize_line(source_line or raw_text, line_pattern)
|
||||||
|
|
||||||
|
base_record = {
|
||||||
|
"source_line": source_line or raw_text,
|
||||||
|
"raw_text": normalized_line or raw_text,
|
||||||
|
"branch": normalize_branch_value(record.get("branch", ""), config),
|
||||||
|
"amount": normalize_amount_text(record.get("amount", "")),
|
||||||
|
"type": str(record.get("type", "")).strip(),
|
||||||
|
"page": str(record.get("page", "")).strip(),
|
||||||
|
"status": normalize_status_value(str(record.get("status", "")).strip(), config),
|
||||||
|
"output_file": str(record.get("output_file", "")).strip(),
|
||||||
|
}
|
||||||
|
if not base_record["page"] and base_record["type"]:
|
||||||
|
base_record["page"] = infer_page_from_type(base_record["type"], config)
|
||||||
|
|
||||||
|
corrected = apply_record_overrides(base_record, overrides, config)
|
||||||
|
corrected["source_line"] = str(corrected.get("source_line") or source_line or raw_text)
|
||||||
|
corrected["raw_text"] = normalize_line(str(corrected.get("raw_text") or normalized_line), line_pattern)
|
||||||
|
if not corrected.get("output_file"):
|
||||||
|
corrected["output_file"] = render_output_filename(config, corrected, 1)
|
||||||
|
validate_record_for_generation(corrected, config)
|
||||||
|
|
||||||
|
gen_result = generate_records(
|
||||||
|
[corrected],
|
||||||
|
config,
|
||||||
|
template_path,
|
||||||
|
output_dir,
|
||||||
|
progress_cb=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
relay_cfg = config.get("relay_handling", {})
|
||||||
|
dedup_cfg = relay_cfg.get("dedup", {}) if isinstance(relay_cfg, dict) else {}
|
||||||
|
key_fields = _normalize_key_fields(dedup_cfg.get("key_fields", ["branch", "amount", "type"]))
|
||||||
|
history_stat = upsert_generated_history(
|
||||||
|
history_path=history_path,
|
||||||
|
generated_items=gen_result.get("generated", []),
|
||||||
|
key_fields=key_fields,
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
remember_rule = bool(payload.get("remember_rule", False))
|
||||||
|
remember_amount = bool(payload.get("remember_amount", False))
|
||||||
|
rule_keyword = str(payload.get("rule_keyword", "")).strip()
|
||||||
|
note = str(payload.get("note", "")).strip()
|
||||||
|
applied_rule = None
|
||||||
|
if remember_rule:
|
||||||
|
rule_updates: dict[str, Any] = {}
|
||||||
|
for field in ("branch", "type", "page", "status", "amount"):
|
||||||
|
if field not in overrides:
|
||||||
|
continue
|
||||||
|
val = str(corrected.get(field, "")).strip()
|
||||||
|
if val:
|
||||||
|
rule_updates[field] = val
|
||||||
|
if not remember_amount:
|
||||||
|
rule_updates.pop("amount", None)
|
||||||
|
if rule_updates:
|
||||||
|
keyword = rule_keyword or infer_correction_rule_keyword(
|
||||||
|
source_line=str(corrected.get("source_line", "")),
|
||||||
|
normalized_line=str(corrected.get("raw_text", "")),
|
||||||
|
corrected_record=corrected,
|
||||||
|
)
|
||||||
|
applied_rule = save_or_update_manual_rule(
|
||||||
|
keyword=keyword,
|
||||||
|
updates=rule_updates,
|
||||||
|
note=note,
|
||||||
|
match_mode=str(payload.get("rule_mode", "normalized")),
|
||||||
|
)
|
||||||
|
|
||||||
|
append_review_log(
|
||||||
|
"manual_correction_apply",
|
||||||
|
{
|
||||||
|
"source_line": str(corrected.get("source_line", "")),
|
||||||
|
"record_before": base_record,
|
||||||
|
"record_after": corrected,
|
||||||
|
"overrides": overrides,
|
||||||
|
"remember_rule": remember_rule,
|
||||||
|
"rule": applied_rule,
|
||||||
|
"note": note,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
issue_resolve_stat = resolve_issue_marks_by_source_line(
|
||||||
|
str(corrected.get("source_line", "")),
|
||||||
|
reason="manual_correction_apply",
|
||||||
|
)
|
||||||
|
if int(issue_resolve_stat.get("count", 0)) > 0:
|
||||||
|
append_review_log(
|
||||||
|
"issue_auto_resolve",
|
||||||
|
{
|
||||||
|
"source_line": str(corrected.get("source_line", "")),
|
||||||
|
"resolved_issue_count": int(issue_resolve_stat.get("count", 0)),
|
||||||
|
"resolved_issue_ids": issue_resolve_stat.get("ids", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": "修正已生成",
|
||||||
|
"generated_count": gen_result.get("generated_count", 0),
|
||||||
|
"generated": gen_result.get("generated", []),
|
||||||
|
"download_images": gen_result.get("download_images", []),
|
||||||
|
"generation_strategy": gen_result.get("generation_strategy", "legacy"),
|
||||||
|
"history": history_stat,
|
||||||
|
"rule": applied_rule,
|
||||||
|
"resolved_issue_count": int(issue_resolve_stat.get("count", 0)),
|
||||||
|
"resolved_issue_ids": issue_resolve_stat.get("ids", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
append_review_log(
|
||||||
|
"manual_correction_error",
|
||||||
|
{
|
||||||
|
"error": str(exc),
|
||||||
|
"record": payload.get("record") if isinstance(payload, dict) else {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(exc)}
|
||||||
|
finally:
|
||||||
|
if slot_acquired and callable(release_generation_slot):
|
||||||
|
release_generation_slot("correction_apply")
|
||||||
47
app/static/app.js
Normal file
47
app/static/app.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Bootstrap loader for legacy /app.js entry.
|
||||||
|
(function bootstrapXibaoWeb() {
|
||||||
|
if (window.__XIBAO_BOOTSTRAPPING__) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.__XIBAO_MAIN_READY__) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.__XIBAO_BOOTSTRAPPING__ = true;
|
||||||
|
|
||||||
|
const currentSrc = String(document.currentScript?.src || "");
|
||||||
|
const currentUrl = currentSrc ? new URL(currentSrc, window.location.href) : null;
|
||||||
|
const currentPath = currentUrl ? String(currentUrl.pathname || "") : "";
|
||||||
|
let basePath = "";
|
||||||
|
if (currentPath.endsWith("/app.js")) {
|
||||||
|
basePath = currentPath.slice(0, -"/app.js".length);
|
||||||
|
}
|
||||||
|
if (basePath === "/") {
|
||||||
|
basePath = "";
|
||||||
|
}
|
||||||
|
window.__XIBAO_BASE_PATH__ = basePath;
|
||||||
|
const version = currentUrl ? currentUrl.searchParams.get("v") : "";
|
||||||
|
const qs = version ? `?v=${encodeURIComponent(version)}` : "";
|
||||||
|
|
||||||
|
function loadScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = src;
|
||||||
|
s.async = false;
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => reject(new Error(`加载失败: ${src}`));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptUrl = (path) => `${basePath}${path}`;
|
||||||
|
|
||||||
|
loadScript(scriptUrl(`/js/core/state.js${qs}`))
|
||||||
|
.then(() => loadScript(scriptUrl(`/js/main.js${qs}`)))
|
||||||
|
.catch((err) => {
|
||||||
|
// Keep UI feedback simple; fallback to console for non-blocking visibility.
|
||||||
|
console.error("[xibao] frontend bootstrap failed:", err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
window.__XIBAO_BOOTSTRAPPING__ = false;
|
||||||
|
});
|
||||||
|
})();
|
||||||
245
app/static/index.html
Normal file
245
app/static/index.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>喜报处理初始版</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css?v=20260227.28" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-shape bg-shape-a"></div>
|
||||||
|
<div class="bg-shape bg-shape-b"></div>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="card composer-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>输入接龙文本</h2>
|
||||||
|
<span class="muted">粘贴后可直接解析或生成</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="raw-text" placeholder="#接龙
|
||||||
|
1、营江路张三30万一年期定期他行挖转
|
||||||
|
2、中心所李四50万保险揽收现金
|
||||||
|
3、营江路所王五20万5年交礼金
|
||||||
|
4、潇水南路保险2万"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<button id="paste-btn" class="secondary">粘贴文本</button>
|
||||||
|
<button id="generate-btn">生成</button>
|
||||||
|
<button id="parse-btn" class="secondary">仅解析预览</button>
|
||||||
|
<button id="force-clear-btn" class="danger">强制清理截图</button>
|
||||||
|
<button id="clear-btn" class="danger">清空历史</button>
|
||||||
|
</div>
|
||||||
|
<div id="progress-wrap" class="progress-wrap hidden">
|
||||||
|
<div class="progress-head">
|
||||||
|
<span id="progress-stage">等待开始</span>
|
||||||
|
<span id="progress-percent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div id="progress-fill" class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-detail" class="muted progress-detail"></div>
|
||||||
|
</div>
|
||||||
|
<div id="msg" class="msg"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card metrics metrics-bar">
|
||||||
|
<div class="metric"><span>输入行</span><strong id="m-input">0</strong></div>
|
||||||
|
<div class="metric"><span>有效解析</span><strong id="m-parsed">0</strong></div>
|
||||||
|
<div class="metric"><span>新增</span><strong id="m-new">0</strong></div>
|
||||||
|
<div class="metric"><span>重复</span><strong id="m-dup">0</strong></div>
|
||||||
|
<div class="metric"><span>跳过</span><strong id="m-skip">0</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<section class="main-col">
|
||||||
|
<section class="card stage-card" id="preview-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>预览图</h2>
|
||||||
|
<span id="preview-note" class="muted">生成后显示</span>
|
||||||
|
</div>
|
||||||
|
<div id="preview-grid" class="preview-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stage-card" id="new-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>新增记录</h2>
|
||||||
|
<span class="muted">自动识别后可手动修正或标识</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>网点</th>
|
||||||
|
<th>金额</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>页面</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>输出文件</th>
|
||||||
|
<th>标记</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="new-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="side-col">
|
||||||
|
<details class="card fold-card" id="dup-panel">
|
||||||
|
<summary>重复记录(可展开)</summary>
|
||||||
|
<div class="fold-body">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>网点</th>
|
||||||
|
<th>金额</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>原因</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dup-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card fold-card" id="skip-panel">
|
||||||
|
<summary>跳过明细(可展开)</summary>
|
||||||
|
<div class="fold-body">
|
||||||
|
<ul id="skip-list" class="skip-list"></ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card fold-card" id="advanced-panel">
|
||||||
|
<summary>高级设置(可选)</summary>
|
||||||
|
<div class="fold-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
模板文件路径(可留空走配置)
|
||||||
|
<input id="template-file" placeholder="/home/yuyx/黄金三十天喜报模版(余额、保险、理财)(1).pptx" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
输出目录(可留空走配置)
|
||||||
|
<input id="output-dir" placeholder="/home/yuyx/xibao_web_initial/output" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="config-summary" class="muted config-summary">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card fold-card" id="history-panel">
|
||||||
|
<summary>历史记录(刷新后仍可见)</summary>
|
||||||
|
<div class="fold-body">
|
||||||
|
<div class="section-head">
|
||||||
|
<span id="history-note" class="muted">加载中...</span>
|
||||||
|
<button id="history-refresh-btn" class="secondary">刷新历史</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>网点</th>
|
||||||
|
<th>金额</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>原始行</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card fold-card" id="issue-panel">
|
||||||
|
<summary>已标识错误(可编辑/删除)</summary>
|
||||||
|
<div class="fold-body">
|
||||||
|
<div class="section-head">
|
||||||
|
<span id="issue-note" class="muted">加载中...</span>
|
||||||
|
<button id="issue-refresh-btn" class="secondary">刷新标识</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>原始行</th>
|
||||||
|
<th>备注</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="issue-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="insurance-modal" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3>检测到保险记录</h3>
|
||||||
|
<p>有保险条目未写明年限,请逐条选择期交年限:</p>
|
||||||
|
<div id="insurance-items" class="insurance-list"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="insurance-submit">确认并继续生成</button>
|
||||||
|
<button id="insurance-cancel" class="danger">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="correction-modal" class="modal hidden">
|
||||||
|
<div class="modal-card correction-card">
|
||||||
|
<h3>手动修正并重生图</h3>
|
||||||
|
<p>保存后只重生当前这条,并可选择记住规则。</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
网点
|
||||||
|
<input id="corr-branch" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
金额(万)
|
||||||
|
<input id="corr-amount" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
类型
|
||||||
|
<input id="corr-type" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
页面
|
||||||
|
<input id="corr-page" placeholder="page_2" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
状态
|
||||||
|
<input id="corr-status" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
备注(可选)
|
||||||
|
<input id="corr-note" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="check-line">
|
||||||
|
<input id="corr-remember" type="checkbox" />
|
||||||
|
记住规则(下次自动套用)
|
||||||
|
</label>
|
||||||
|
<label id="corr-keyword-wrap" class="hidden">
|
||||||
|
规则关键词(可留空自动推断)
|
||||||
|
<input id="corr-keyword" placeholder="如:揽收他行" />
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button id="corr-submit">保存并生成</button>
|
||||||
|
<button id="corr-cancel" class="danger">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./app.js?v=20260227.28"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
app/static/js/README.md
Normal file
10
app/static/js/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Frontend JS Layout
|
||||||
|
|
||||||
|
- `core/state.js`
|
||||||
|
- Shared runtime state and constant maps.
|
||||||
|
- `main.js`
|
||||||
|
- Main production script: full feature logic + Vue app section.
|
||||||
|
|
||||||
|
`/static/app.js` is the bootstrap entry. It only loads `core/state.js` and `main.js`.
|
||||||
|
|
||||||
|
> Note: `/opt/xibao-web/app/app.js` is a legacy file and is not served by the site.
|
||||||
52
app/static/js/core/state.js
Normal file
52
app/static/js/core/state.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Frontend core state/constants for xibao web.
|
||||||
|
(function initXibaoCore(globalObj) {
|
||||||
|
if (globalObj.XIBAO_CORE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
lastResult: null,
|
||||||
|
keyFields: ["branch", "amount", "type"],
|
||||||
|
lastGeneratedImages: [],
|
||||||
|
activeProgressToken: "",
|
||||||
|
progressTimer: null,
|
||||||
|
correctionContext: null,
|
||||||
|
imageBlobCache: new Map(),
|
||||||
|
imageBlobPromises: new Map(),
|
||||||
|
toastTimer: null,
|
||||||
|
previewLoadToken: 0,
|
||||||
|
previewLoadConcurrency: 3,
|
||||||
|
previewLoadedCount: 0,
|
||||||
|
imageDeliveryMaxKbps: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKIP_REASON_MAP = {
|
||||||
|
skip_line_rule: "说明/标题行",
|
||||||
|
branch_not_found: "未识别网点",
|
||||||
|
type_not_found: "未识别产品类型或期限",
|
||||||
|
amount_not_found: "未识别金额",
|
||||||
|
demand_deposit_not_generate: "活期类信息且未写明确期限,默认不生成",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DUP_REASON_MAP = {
|
||||||
|
history_duplicate: "历史重复",
|
||||||
|
input_duplicate: "本次重复",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARK_TYPE_MAP = {
|
||||||
|
recognition_error: "识别错误",
|
||||||
|
generation_error: "生成错误",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DUP_AUTO_OPEN_LIMIT = 20;
|
||||||
|
const PREVIEW_WARM_LIMIT = 12;
|
||||||
|
|
||||||
|
globalObj.XIBAO_CORE = {
|
||||||
|
state,
|
||||||
|
SKIP_REASON_MAP,
|
||||||
|
DUP_REASON_MAP,
|
||||||
|
MARK_TYPE_MAP,
|
||||||
|
DUP_AUTO_OPEN_LIMIT,
|
||||||
|
PREVIEW_WARM_LIMIT,
|
||||||
|
};
|
||||||
|
})(window);
|
||||||
2121
app/static/js/main.js
Normal file
2121
app/static/js/main.js
Normal file
File diff suppressed because it is too large
Load Diff
914
app/static/styles.css
Normal file
914
app/static/styles.css
Normal file
@@ -0,0 +1,914 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Noto+Sans+SC:wght@400;500;700;900&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-0: #edf3f4;
|
||||||
|
--bg-1: #f8f5ed;
|
||||||
|
--surface: rgba(255, 255, 255, 0.92);
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--ink: #182c32;
|
||||||
|
--muted: #61777d;
|
||||||
|
--line: #d4e0e4;
|
||||||
|
--line-strong: #c3d2d8;
|
||||||
|
--brand: #0e766e;
|
||||||
|
--brand-deep: #0a5b56;
|
||||||
|
--accent: #d77c2e;
|
||||||
|
--danger: #c53d34;
|
||||||
|
--focus: rgba(14, 118, 110, 0.22);
|
||||||
|
--shadow-soft: 0 10px 28px rgba(18, 45, 52, 0.1);
|
||||||
|
--shadow-strong: 0 22px 48px rgba(17, 43, 49, 0.16);
|
||||||
|
--radius-xl: 22px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Sora", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink);
|
||||||
|
background: radial-gradient(circle at 7% -12%, rgba(14, 118, 110, 0.18) 0, transparent 36%),
|
||||||
|
radial-gradient(circle at 94% 0, rgba(215, 124, 46, 0.18) 0, transparent 32%),
|
||||||
|
linear-gradient(165deg, var(--bg-0), var(--bg-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-shape {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-shape-a {
|
||||||
|
top: -130px;
|
||||||
|
right: -100px;
|
||||||
|
width: 340px;
|
||||||
|
height: 340px;
|
||||||
|
border-radius: 44% 56% 59% 41%;
|
||||||
|
background: rgba(14, 118, 110, 0.12);
|
||||||
|
animation: float-a 13s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-shape-b {
|
||||||
|
left: -92px;
|
||||||
|
bottom: -92px;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 56% 44% 40% 60%;
|
||||||
|
background: rgba(215, 124, 46, 0.13);
|
||||||
|
animation: float-b 14s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-a {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-8px, 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-b {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(9px, -11px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1460px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 34px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: linear-gradient(118deg, rgba(255, 255, 255, 0.94), rgba(248, 252, 252, 0.82));
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(27px, 3.7vw, 42px);
|
||||||
|
letter-spacing: 0.012em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-copy p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(14, 118, 110, 0.24);
|
||||||
|
background: rgba(14, 118, 110, 0.09);
|
||||||
|
color: var(--brand-deep);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card {
|
||||||
|
border-color: rgba(14, 118, 110, 0.28);
|
||||||
|
box-shadow: var(--shadow-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(340px, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-col {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-col {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
position: sticky;
|
||||||
|
top: 14px;
|
||||||
|
max-height: calc(100vh - 28px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-card {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-card .preview-grid {
|
||||||
|
max-height: 700px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new-card .table-wrap {
|
||||||
|
max-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea,
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 184px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 13px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus,
|
||||||
|
input:focus,
|
||||||
|
button:focus-visible,
|
||||||
|
summary:focus-visible {
|
||||||
|
border-color: rgba(14, 118, 110, 0.56);
|
||||||
|
box-shadow: 0 0 0 4px var(--focus);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.mini-btn,
|
||||||
|
.preview-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(138deg, var(--brand), var(--brand-deep));
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.mini-btn:hover,
|
||||||
|
.preview-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 18px rgba(11, 84, 78, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled,
|
||||||
|
.mini-btn:disabled,
|
||||||
|
.preview-btn:disabled {
|
||||||
|
opacity: 0.56;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary,
|
||||||
|
.mini-btn.secondary,
|
||||||
|
.preview-btn.secondary {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
background: #f7fbfc;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover,
|
||||||
|
.mini-btn.secondary:hover,
|
||||||
|
.preview-btn.secondary:hover {
|
||||||
|
background: #eef5f7;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger,
|
||||||
|
.mini-btn.danger,
|
||||||
|
.preview-btn.danger {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(138deg, #cd4d45, #b8362e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-wrap {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #e4ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--brand), var(--accent));
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-detail {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
min-height: 22px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card > summary::after {
|
||||||
|
content: "展开";
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card[open] > summary {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(249, 252, 252, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card[open] > summary::after {
|
||||||
|
content: "收起";
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-body {
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label,
|
||||||
|
#corr-keyword-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-summary {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #ffffff;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-panel .table-wrap,
|
||||||
|
#issue-panel .table-wrap {
|
||||||
|
max-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dup-panel .table-wrap {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 720px;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #ecf2f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #f4f9fa;
|
||||||
|
color: #405960;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover td {
|
||||||
|
background: #fbfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new-body td:nth-child(3),
|
||||||
|
#dup-body td:nth-child(3),
|
||||||
|
#history-body td:nth-child(4) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-actions {
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-inline,
|
||||||
|
.skip-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn {
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
align-content: start;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 24px 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-media {
|
||||||
|
width: 100%;
|
||||||
|
background: #f2f8f9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-media.is-loading::after,
|
||||||
|
.preview-media.is-error::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #597278;
|
||||||
|
background: rgba(242, 248, 249, 0.88);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-media.is-loading::after {
|
||||||
|
content: "加载中...";
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-media.is-error::after {
|
||||||
|
content: "加载失败";
|
||||||
|
color: #8f3a33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
min-height: 220px;
|
||||||
|
aspect-ratio: 1 / 2;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
background: #eef4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid #dfeaed;
|
||||||
|
min-height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4d666d;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-list li {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-list li > span {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-line {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-line input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(18, 36, 42, 0.48);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(880px, calc(100vw - 30px));
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 30px 56px rgba(13, 34, 41, 0.32);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-card .form-grid {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insurance-list {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insurance-item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #f8fbfc;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insurance-line {
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insurance-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insurance-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insurance-options label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insurance-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-msg {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 26px;
|
||||||
|
transform: translate(-50%, 12px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 70;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(18, 41, 49, 0.92);
|
||||||
|
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.22);
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-msg.show {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-msg.error {
|
||||||
|
background: rgba(163, 39, 31, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-card .preview-grid {
|
||||||
|
max-height: 520px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.metrics-bar {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-col {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-panel .table-wrap,
|
||||||
|
#issue-panel .table-wrap,
|
||||||
|
#dup-panel .table-wrap,
|
||||||
|
#new-card .table-wrap,
|
||||||
|
#preview-card .preview-grid,
|
||||||
|
.skip-list {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.container {
|
||||||
|
padding: 16px 12px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-card > summary {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-body {
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-bar {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row button {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.top-meta span {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row button {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-bar {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 92vh;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/static/vendor/vue.global.prod.js
vendored
Normal file
13
app/static/vendor/vue.global.prod.js
vendored
Normal file
File diff suppressed because one or more lines are too long
0
app/tests/__init__.py
Normal file
0
app/tests/__init__.py
Normal file
89
app/tests/test_generation_strategy.py
Normal file
89
app/tests/test_generation_strategy.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from server import (
|
||||||
|
resolve_build_workers,
|
||||||
|
resolve_extract_workers,
|
||||||
|
resolve_generation_strategy,
|
||||||
|
resolve_image_delivery_options,
|
||||||
|
resolve_memory_reclaim_options,
|
||||||
|
resolve_single_generation_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerationStrategy(unittest.TestCase):
|
||||||
|
def test_default_without_performance_uses_page_template_cache(self) -> None:
|
||||||
|
self.assertEqual(resolve_generation_strategy({}, total_records=1), "legacy")
|
||||||
|
self.assertEqual(resolve_generation_strategy({}, total_records=5), "page_template_cache")
|
||||||
|
|
||||||
|
def test_page_template_cache_requires_min_records(self) -> None:
|
||||||
|
cfg = {
|
||||||
|
"performance": {
|
||||||
|
"generation_strategy": "page_template_cache",
|
||||||
|
"template_cache_min_records": 2,
|
||||||
|
"single_slide_output": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertEqual(resolve_generation_strategy(cfg, total_records=1), "legacy")
|
||||||
|
self.assertEqual(resolve_generation_strategy(cfg, total_records=2), "page_template_cache")
|
||||||
|
|
||||||
|
def test_page_template_cache_respects_single_slide_output(self) -> None:
|
||||||
|
cfg = {
|
||||||
|
"performance": {
|
||||||
|
"generation_strategy": "page_template_cache",
|
||||||
|
"template_cache_min_records": 1,
|
||||||
|
"single_slide_output": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertEqual(resolve_generation_strategy(cfg, total_records=3), "legacy")
|
||||||
|
|
||||||
|
def test_legacy_strategy_always_returns_legacy(self) -> None:
|
||||||
|
cfg = {
|
||||||
|
"performance": {
|
||||||
|
"generation_strategy": "legacy",
|
||||||
|
"template_cache_min_records": 1,
|
||||||
|
"single_slide_output": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertEqual(resolve_generation_strategy(cfg, total_records=20), "legacy")
|
||||||
|
|
||||||
|
def test_image_delivery_defaults(self) -> None:
|
||||||
|
opts = resolve_image_delivery_options({})
|
||||||
|
self.assertEqual(int(opts.get("max_kbps", 0)), 300)
|
||||||
|
self.assertEqual(int(opts.get("chunk_size", 0)), 16 * 1024)
|
||||||
|
|
||||||
|
def test_image_delivery_disable_limit(self) -> None:
|
||||||
|
cfg = {"performance": {"image_delivery": {"enabled": False, "max_kbps": 999}}}
|
||||||
|
opts = resolve_image_delivery_options(cfg)
|
||||||
|
self.assertEqual(int(opts.get("max_kbps", -1)), 0)
|
||||||
|
|
||||||
|
def test_single_generation_mode_defaults_enabled(self) -> None:
|
||||||
|
self.assertTrue(resolve_single_generation_mode({}))
|
||||||
|
self.assertFalse(resolve_single_generation_mode({"performance": {"single_generation_mode": False}}))
|
||||||
|
|
||||||
|
def test_build_workers_default_single(self) -> None:
|
||||||
|
self.assertEqual(resolve_build_workers({}, total_records=10), 1)
|
||||||
|
cfg = {"performance": {"max_build_workers": 3}}
|
||||||
|
self.assertEqual(resolve_build_workers(cfg, total_records=2), 2)
|
||||||
|
|
||||||
|
def test_extract_workers_default_single(self) -> None:
|
||||||
|
self.assertEqual(resolve_extract_workers({}), 1)
|
||||||
|
cfg = {"performance": {"max_extract_workers": 4}}
|
||||||
|
self.assertGreaterEqual(resolve_extract_workers(cfg), 1)
|
||||||
|
|
||||||
|
def test_memory_reclaim_defaults_enabled(self) -> None:
|
||||||
|
opts = resolve_memory_reclaim_options({})
|
||||||
|
self.assertTrue(opts["enabled"])
|
||||||
|
self.assertTrue(opts["gc_collect"])
|
||||||
|
self.assertTrue(opts["malloc_trim"])
|
||||||
|
|
||||||
|
def test_memory_reclaim_can_disable(self) -> None:
|
||||||
|
opts = resolve_memory_reclaim_options({"performance": {"memory_reclaim": {"enabled": False}}})
|
||||||
|
self.assertFalse(opts["enabled"])
|
||||||
|
self.assertFalse(opts["gc_collect"])
|
||||||
|
self.assertFalse(opts["malloc_trim"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
30
app/tests/test_parse_amount_split.py
Normal file
30
app/tests/test_parse_amount_split.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from server import load_config, parse_records
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseAmountSplit(unittest.TestCase):
|
||||||
|
def test_amount_before_comma_and_term_after_comma(self) -> None:
|
||||||
|
config = load_config()
|
||||||
|
raw_text = "#接龙\n11、 四马桥一潘纪君 拜访门窗商户微信提现15万,存定期一年"
|
||||||
|
|
||||||
|
result = parse_records(raw_text, config, history=[])
|
||||||
|
|
||||||
|
records = result.get("new_records", [])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
str(item.get("branch", "")) == "四马桥"
|
||||||
|
and str(item.get("type", "")) == "一年期定期"
|
||||||
|
and str(item.get("amount", "")) == "15万"
|
||||||
|
for item in records
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
skipped_reasons = [str(x.get("reason", "")) for x in result.get("skipped", [])]
|
||||||
|
self.assertNotIn("amount_not_found", skipped_reasons)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
163
app/tests/test_services.py
Normal file
163
app/tests/test_services.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from services.post_ops import (
|
||||||
|
run_history_clear,
|
||||||
|
run_mark_issue,
|
||||||
|
)
|
||||||
|
from services.workflows import (
|
||||||
|
run_correction_apply_api,
|
||||||
|
run_generate_api,
|
||||||
|
run_parse_api,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTestBase(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.progress_calls: list[tuple[str, str, int, str, str]] = []
|
||||||
|
|
||||||
|
def base_ctx(self) -> dict:
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def set_progress(token: str, *, status: str, stage: str, percent: int, detail: str = "", error: str = ""):
|
||||||
|
self.progress_calls.append((token, status, int(percent), str(stage), str(detail or error)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"normalize_insurance_year": lambda v: str(v) if v in {"3", "5"} else None,
|
||||||
|
"normalize_insurance_year_choices": lambda v: v if isinstance(v, dict) else {},
|
||||||
|
"load_config": lambda: {
|
||||||
|
"relay_handling": {
|
||||||
|
"dedup": {"key_fields": ["branch", "amount", "type"]},
|
||||||
|
"parse_rules": {"line_pattern": r"^\\d+、\\s*"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolve_history_path": lambda config: Path("/tmp/xibao_test_history.json"),
|
||||||
|
"resolve_template_path": lambda config, override=None: Path("/tmp/template.pptx"),
|
||||||
|
"resolve_output_dir": lambda config, override=None: Path("/tmp/xibao_output"),
|
||||||
|
"load_history": lambda history_path: [],
|
||||||
|
"save_history": lambda history_path, records: None,
|
||||||
|
"parse_records": lambda raw_text, config, history, insurance_year_choice, insurance_year_choices: {
|
||||||
|
"has_trigger": True,
|
||||||
|
"records": [],
|
||||||
|
"new_records": [],
|
||||||
|
"skipped": [],
|
||||||
|
},
|
||||||
|
"generate_records": lambda new_records, config, template_path, output_dir, progress_cb=None: {
|
||||||
|
"generated_count": len(new_records),
|
||||||
|
"generated": list(new_records),
|
||||||
|
"download_images": [],
|
||||||
|
},
|
||||||
|
"set_generation_progress": set_progress,
|
||||||
|
"append_review_log": lambda event, payload=None: "/tmp/review.jsonl",
|
||||||
|
"log_parse_skipped": lambda skipped, source: 0,
|
||||||
|
"append_new_history": lambda history_path, history, records, key_fields: {
|
||||||
|
"added": len(records),
|
||||||
|
"total": len(history) + len(records),
|
||||||
|
},
|
||||||
|
"upsert_issue_mark": lambda **kwargs: ({"id": "issue_1", **kwargs}, True),
|
||||||
|
"suppress_skip_item": lambda line, reason="": ({"id": "skip_1", "line": line, "reason": reason}, True),
|
||||||
|
"normalize_line": lambda line, pattern: str(line or "").strip(),
|
||||||
|
"normalize_branch_value": lambda value, config: str(value or "").strip(),
|
||||||
|
"normalize_amount_text": lambda value: str(value or "").strip(),
|
||||||
|
"normalize_status_value": lambda value, config: str(value or "").strip(),
|
||||||
|
"infer_page_from_type": lambda type_keyword, config: "page_2",
|
||||||
|
"apply_record_overrides": lambda record, overrides, config: {**record, **(overrides or {})},
|
||||||
|
"render_output_filename": lambda config, record, index: f"喜报_{index}.png",
|
||||||
|
"validate_record_for_generation": lambda record, config: None,
|
||||||
|
"upsert_history_records": lambda history_path, history, records, key_fields: {
|
||||||
|
"added": len(records),
|
||||||
|
"updated": 0,
|
||||||
|
},
|
||||||
|
"infer_correction_rule_keyword": lambda **kwargs: "keyword",
|
||||||
|
"save_or_update_manual_rule": lambda **kwargs: {"keyword": kwargs.get("keyword", "keyword")},
|
||||||
|
"resolve_issue_marks_by_source_line": lambda source_line, reason="": {"count": 0, "ids": []},
|
||||||
|
"update_issue_mark": lambda **kwargs: {"id": kwargs.get("issue_id", "")},
|
||||||
|
"delete_issue_mark": lambda issue_id: True,
|
||||||
|
"cleanup_output_artifacts": lambda output_dir: {"removed_dirs": 0, "removed_files": 0},
|
||||||
|
"clear_skip_suppressions": lambda: 0,
|
||||||
|
"_HISTORY_LOCK": lock,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkflows(ServiceTestBase):
|
||||||
|
def test_run_parse_api_success(self) -> None:
|
||||||
|
ctx = self.base_ctx()
|
||||||
|
status, body = run_parse_api({"raw_text": "#接龙\n1、测试"}, ctx)
|
||||||
|
self.assertEqual(int(status), 200)
|
||||||
|
self.assertTrue(body.get("ok"))
|
||||||
|
self.assertIn("result", body)
|
||||||
|
|
||||||
|
def test_run_generate_api_requires_insurance(self) -> None:
|
||||||
|
ctx = self.base_ctx()
|
||||||
|
|
||||||
|
def parse_records(*args, **kwargs):
|
||||||
|
return {
|
||||||
|
"has_trigger": True,
|
||||||
|
"records": [],
|
||||||
|
"new_records": [{"branch": "营江路", "amount": "10万", "type": "一年期定期"}],
|
||||||
|
"skipped": [],
|
||||||
|
"needs_insurance_choice": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx["parse_records"] = parse_records
|
||||||
|
status, body = run_generate_api({"raw_text": "#接龙\n1、测试"}, ctx)
|
||||||
|
self.assertEqual(int(status), 400)
|
||||||
|
self.assertEqual(body.get("error_code"), "insurance_year_required")
|
||||||
|
self.assertTrue(any(item[1] == "need_input" for item in self.progress_calls))
|
||||||
|
|
||||||
|
def test_run_generate_api_success(self) -> None:
|
||||||
|
ctx = self.base_ctx()
|
||||||
|
|
||||||
|
def parse_records(*args, **kwargs):
|
||||||
|
return {
|
||||||
|
"has_trigger": True,
|
||||||
|
"records": [],
|
||||||
|
"new_records": [
|
||||||
|
{
|
||||||
|
"source_line": "1、营江路揽收现金10万存一年",
|
||||||
|
"raw_text": "营江路揽收现金10万存一年",
|
||||||
|
"branch": "营江路",
|
||||||
|
"amount": "10万",
|
||||||
|
"type": "一年期定期",
|
||||||
|
"page": "page_2",
|
||||||
|
"status": "揽收现金",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"skipped": [],
|
||||||
|
"needs_insurance_choice": False,
|
||||||
|
"dedup_key_fields": ["branch", "amount", "type"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx["parse_records"] = parse_records
|
||||||
|
status, body = run_generate_api({"raw_text": "#接龙\n1、测试", "save_history": True}, ctx)
|
||||||
|
self.assertEqual(int(status), 200)
|
||||||
|
self.assertTrue(body.get("ok"))
|
||||||
|
self.assertEqual(int(body.get("generated_count", 0)), 1)
|
||||||
|
self.assertTrue(any(item[1] == "done" for item in self.progress_calls))
|
||||||
|
|
||||||
|
def test_run_correction_apply_api_validation(self) -> None:
|
||||||
|
ctx = self.base_ctx()
|
||||||
|
status, body = run_correction_apply_api({}, ctx)
|
||||||
|
self.assertEqual(int(status), 400)
|
||||||
|
self.assertFalse(body.get("ok", True))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostOps(ServiceTestBase):
|
||||||
|
def test_run_mark_issue_invalid_type(self) -> None:
|
||||||
|
ctx = self.base_ctx()
|
||||||
|
status, body = run_mark_issue({"mark_type": "bad", "source_line": "x"}, ctx)
|
||||||
|
self.assertEqual(int(status), 400)
|
||||||
|
self.assertIn("mark_type", body.get("error", ""))
|
||||||
|
|
||||||
|
def test_run_history_clear_success(self) -> None:
|
||||||
|
ctx = self.base_ctx()
|
||||||
|
status, body = run_history_clear(ctx)
|
||||||
|
self.assertEqual(int(status), 200)
|
||||||
|
self.assertTrue(body.get("ok"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
41
app/tests/test_status_alias.py
Normal file
41
app/tests/test_status_alias.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from server import load_config, parse_records
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusAlias(unittest.TestCase):
|
||||||
|
def test_collect_external_bank_maps_to_collect_other_bank(self) -> None:
|
||||||
|
config = load_config()
|
||||||
|
raw_text = "#接龙\n21、 濂溪揽收外行5.3万存一年"
|
||||||
|
|
||||||
|
result = parse_records(raw_text, config, history=[])
|
||||||
|
records = result.get("new_records", [])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
str(item.get("branch", "")) == "濂溪"
|
||||||
|
and str(item.get("type", "")) == "一年期定期"
|
||||||
|
and str(item.get("status", "")) == "揽收他行"
|
||||||
|
for item in records
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_transfer_external_bank_maps_to_transfer_other_bank(self) -> None:
|
||||||
|
config = load_config()
|
||||||
|
raw_text = "#接龙\n22、 潇水南路挖转外行20万存半年"
|
||||||
|
|
||||||
|
result = parse_records(raw_text, config, history=[])
|
||||||
|
records = result.get("new_records", [])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
str(item.get("branch", "")) == "潇水南路"
|
||||||
|
and str(item.get("type", "")) == "六个月定期"
|
||||||
|
and str(item.get("status", "")) == "他行挖转"
|
||||||
|
for item in records
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
82
app/tests/test_wechat_bot_bridge_skip.py
Normal file
82
app/tests/test_wechat_bot_bridge_skip.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from argparse import Namespace
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from wechat_bot_bridge import WechatXibaoBridge
|
||||||
|
|
||||||
|
|
||||||
|
class WechatBotBridgeSkipTests(unittest.TestCase):
|
||||||
|
def _new_bridge(self, tmp_dir: str) -> WechatXibaoBridge:
|
||||||
|
base = Path(tmp_dir)
|
||||||
|
args = Namespace(
|
||||||
|
wechat_base_url="http://127.0.0.1:18238",
|
||||||
|
xibao_base_url="http://127.0.0.1:8787",
|
||||||
|
wechat_auth_key="",
|
||||||
|
wechat_session_file=str(base / "session.json"),
|
||||||
|
sync_count=30,
|
||||||
|
poll_interval=2.0,
|
||||||
|
max_images=3,
|
||||||
|
once=True,
|
||||||
|
dry_run=True,
|
||||||
|
allow_from="",
|
||||||
|
state_file=str(base / "state.json"),
|
||||||
|
meta_file=str(base / "meta.json"),
|
||||||
|
daily_cleanup_time="00:10",
|
||||||
|
)
|
||||||
|
return WechatXibaoBridge(args)
|
||||||
|
|
||||||
|
def test_parse_skip_command(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp_dir:
|
||||||
|
bridge = self._new_bridge(tmp_dir)
|
||||||
|
cmd = bridge.parse_command("跳过3")
|
||||||
|
self.assertIsNotNone(cmd)
|
||||||
|
self.assertEqual(cmd.get("action"), "set_skip")
|
||||||
|
self.assertEqual(cmd.get("count"), "3")
|
||||||
|
|
||||||
|
cmd2 = bridge.parse_command("跳过(12)")
|
||||||
|
self.assertIsNotNone(cmd2)
|
||||||
|
self.assertEqual(cmd2.get("action"), "set_skip")
|
||||||
|
self.assertEqual(cmd2.get("count"), "12")
|
||||||
|
|
||||||
|
def test_apply_daily_skip_to_numbered_lines(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp_dir:
|
||||||
|
bridge = self._new_bridge(tmp_dir)
|
||||||
|
bridge.set_daily_skip("wxid_u1", 2)
|
||||||
|
|
||||||
|
raw = "#接龙\n1、第一条\n2、第二条\n3、第三条\n说明文本"
|
||||||
|
text, removed, requested = bridge.apply_daily_skip_to_raw_text("wxid_u1", raw)
|
||||||
|
self.assertEqual(requested, 2)
|
||||||
|
self.assertEqual(removed, 2)
|
||||||
|
self.assertIn("3、第三条", text)
|
||||||
|
self.assertNotIn("1、第一条", text)
|
||||||
|
self.assertNotIn("2、第二条", text)
|
||||||
|
|
||||||
|
def test_skip_zero_clears_setting(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp_dir:
|
||||||
|
bridge = self._new_bridge(tmp_dir)
|
||||||
|
bridge.set_daily_skip("wxid_u2", 5)
|
||||||
|
self.assertEqual(bridge.get_daily_skip("wxid_u2"), 5)
|
||||||
|
bridge.set_daily_skip("wxid_u2", 0)
|
||||||
|
self.assertEqual(bridge.get_daily_skip("wxid_u2"), 0)
|
||||||
|
|
||||||
|
def test_stale_daily_skip_is_invalidated(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp_dir:
|
||||||
|
bridge = self._new_bridge(tmp_dir)
|
||||||
|
bridge.meta["daily_skip"] = {
|
||||||
|
"wxid_old": {
|
||||||
|
"date": "2000-01-01",
|
||||||
|
"count": 3,
|
||||||
|
"updated_at": "2000-01-01T00:00:00",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value = bridge.get_daily_skip("wxid_old")
|
||||||
|
self.assertEqual(value, 0)
|
||||||
|
self.assertNotIn("wxid_old", bridge.meta.get("daily_skip", {}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1125
app/wechat_bot_bridge.py
Normal file
1125
app/wechat_bot_bridge.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user