From 0951732c7a01badd0e0e4faf9da3fcba528fa72c Mon Sep 17 00:00:00 2001
From: 237899745 <237899745@git.workyai.cn>
Date: Fri, 27 Feb 2026 15:21:15 +0800
Subject: [PATCH] feat: initial import (exclude templates and runtime temp
files)
---
.gitignore | 35 +
app/Makefile | 15 +
app/README.md | 294 ++
app/api_get_routes.py | 199 ++
app/api_post_routes.py | 115 +
app/app.js | 1196 ++++++++
app/config/xibao_logic.json | 392 +++
app/package-lock.json | 370 +++
app/package.json | 16 +
app/repositories/__init__.py | 0
app/repositories/history_repository.py | 58 +
app/requirements.txt | 1 +
app/scripts/bootstrap.sh | 16 +
app/scripts/smoke_api.sh | 28 +
app/scripts/sync_vue_vendor.sh | 17 +
app/server.py | 3348 ++++++++++++++++++++++
app/services/__init__.py | 0
app/services/post_ops.py | 218 ++
app/services/workflows.py | 448 +++
app/static/app.js | 47 +
app/static/index.html | 245 ++
app/static/js/README.md | 10 +
app/static/js/core/state.js | 52 +
app/static/js/main.js | 2121 ++++++++++++++
app/static/styles.css | 914 ++++++
app/static/vendor/vue.global.prod.js | 13 +
app/tests/__init__.py | 0
app/tests/test_generation_strategy.py | 89 +
app/tests/test_parse_amount_split.py | 30 +
app/tests/test_services.py | 163 ++
app/tests/test_status_alias.py | 41 +
app/tests/test_wechat_bot_bridge_skip.py | 82 +
app/wechat_bot_bridge.py | 1125 ++++++++
33 files changed, 11698 insertions(+)
create mode 100644 .gitignore
create mode 100644 app/Makefile
create mode 100644 app/README.md
create mode 100644 app/api_get_routes.py
create mode 100644 app/api_post_routes.py
create mode 100644 app/app.js
create mode 100644 app/config/xibao_logic.json
create mode 100644 app/package-lock.json
create mode 100644 app/package.json
create mode 100644 app/repositories/__init__.py
create mode 100644 app/repositories/history_repository.py
create mode 100644 app/requirements.txt
create mode 100755 app/scripts/bootstrap.sh
create mode 100755 app/scripts/smoke_api.sh
create mode 100755 app/scripts/sync_vue_vendor.sh
create mode 100644 app/server.py
create mode 100644 app/services/__init__.py
create mode 100644 app/services/post_ops.py
create mode 100644 app/services/workflows.py
create mode 100644 app/static/app.js
create mode 100644 app/static/index.html
create mode 100644 app/static/js/README.md
create mode 100644 app/static/js/core/state.js
create mode 100644 app/static/js/main.js
create mode 100644 app/static/styles.css
create mode 100644 app/static/vendor/vue.global.prod.js
create mode 100644 app/tests/__init__.py
create mode 100644 app/tests/test_generation_strategy.py
create mode 100644 app/tests/test_parse_amount_split.py
create mode 100644 app/tests/test_services.py
create mode 100644 app/tests/test_status_alias.py
create mode 100644 app/tests/test_wechat_bot_bridge_skip.py
create mode 100644 app/wechat_bot_bridge.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9dec4c9
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/app/Makefile b/app/Makefile
new file mode 100644
index 0000000..b816768
--- /dev/null
+++ b/app/Makefile
@@ -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
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 0000000..f678256
--- /dev/null
+++ b/app/README.md
@@ -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 小时过期。
diff --git a/app/api_get_routes.py b/app/api_get_routes.py
new file mode 100644
index 0000000..4acf04c
--- /dev/null
+++ b/app/api_get_routes.py
@@ -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
diff --git a/app/api_post_routes.py b/app/api_post_routes.py
new file mode 100644
index 0000000..f8b6737
--- /dev/null
+++ b/app/api_post_routes.py
@@ -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
diff --git a/app/app.js b/app/app.js
new file mode 100644
index 0000000..3e9e190
--- /dev/null
+++ b/app/app.js
@@ -0,0 +1,1196 @@
+// DEPRECATED: this file is not served by zc.workyai.cn.
+// Active frontend entry is /opt/xibao-web/app/static/app.js.
+const state = {
+ lastResult: null,
+ keyFields: ["branch", "amount", "type"],
+ lastGeneratedImages: [],
+ activeProgressToken: "",
+ progressTimer: null,
+ correctionContext: null,
+};
+
+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: "本次重复",
+};
+
+function setMsg(text, isError = false) {
+ const el = document.getElementById("msg");
+ el.textContent = text || "";
+ el.style.color = isError ? "#9f2f2f" : "#5b6f69";
+}
+
+function setProgressVisible(visible) {
+ const wrap = document.getElementById("progress-wrap");
+ if (!wrap) {
+ return;
+ }
+ wrap.classList.toggle("hidden", !visible);
+}
+
+function renderProgress(progress) {
+ const stageEl = document.getElementById("progress-stage");
+ const pctEl = document.getElementById("progress-percent");
+ const detailEl = document.getElementById("progress-detail");
+ const fillEl = document.getElementById("progress-fill");
+ if (!stageEl || !pctEl || !detailEl || !fillEl) {
+ return;
+ }
+
+ const stage = progress?.stage || "处理中";
+ const percent = Math.max(0, Math.min(100, Number(progress?.percent ?? 0)));
+ const detail = progress?.detail || "";
+ const status = progress?.status || "";
+ const error = progress?.error || "";
+
+ stageEl.textContent = stage;
+ pctEl.textContent = `${percent}%`;
+ fillEl.style.width = `${percent}%`;
+ if (status === "error" && error) {
+ detailEl.textContent = `${detail ? `${detail} - ` : ""}${error}`;
+ } else {
+ detailEl.textContent = detail;
+ }
+}
+
+function stopProgressPolling() {
+ if (state.progressTimer) {
+ clearInterval(state.progressTimer);
+ state.progressTimer = null;
+ }
+}
+
+async function fetchProgressOnce(token) {
+ if (!token) {
+ return;
+ }
+ const res = await fetch(`/api/progress/${encodeURIComponent(token)}`);
+ let data = {};
+ try {
+ data = await res.json();
+ } catch (e) {
+ return;
+ }
+ if (!data.ok || !data.progress) {
+ return;
+ }
+ renderProgress(data.progress);
+ if (data.progress.status === "done" || data.progress.status === "error") {
+ stopProgressPolling();
+ }
+}
+
+function startProgressPolling(token) {
+ stopProgressPolling();
+ state.activeProgressToken = token || "";
+ if (!state.activeProgressToken) {
+ return;
+ }
+ state.progressTimer = setInterval(() => {
+ void fetchProgressOnce(state.activeProgressToken);
+ }, 700);
+}
+
+function getRawText() {
+ return document.getElementById("raw-text").value.trim();
+}
+
+function getTemplateFile() {
+ return document.getElementById("template-file").value.trim();
+}
+
+function getOutputDir() {
+ return document.getElementById("output-dir").value.trim();
+}
+
+function translateSkipReason(reason) {
+ if (!reason) {
+ return "未知原因";
+ }
+ if (reason.startsWith("branch_not_allowed:")) {
+ const branch = reason.split(":", 2)[1] || "";
+ return `网点不在白名单: ${branch}`;
+ }
+ return SKIP_REASON_MAP[reason] || reason;
+}
+
+function translateDupReason(reason) {
+ return DUP_REASON_MAP[reason] || reason || "未知原因";
+}
+
+function normalizeSkipLineText(line) {
+ return String(line || "").replace(/\s+/g, "").trim();
+}
+
+function isSameSkippedItem(a, b) {
+ const lineA = normalizeSkipLineText(a?.line || "");
+ const lineB = normalizeSkipLineText(b?.line || "");
+ const reasonA = String(a?.reason || "").trim();
+ const reasonB = String(b?.reason || "").trim();
+ return lineA && lineA === lineB && reasonA === reasonB;
+}
+
+function toInlineUrl(url) {
+ if (!url) {
+ return "";
+ }
+ return url.includes("?") ? `${url}&inline=1` : `${url}?inline=1`;
+}
+
+async function postJson(url, body) {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body || {}),
+ });
+
+ let data = {};
+ try {
+ data = await res.json();
+ } catch (e) {
+ throw new Error(`接口返回非JSON: ${url}`);
+ }
+
+ return { status: res.status, data };
+}
+
+function normalizeAmountInput(raw) {
+ const s = String(raw || "").trim();
+ if (!s) {
+ return "";
+ }
+ const m = s.match(/\d+(?:\.\d+)?/);
+ if (!m) {
+ return s;
+ }
+ const n = Math.max(0, Math.floor(Number(m[0]) || 0));
+ return `${n}万`;
+}
+
+function toEditableAmount(raw) {
+ const s = String(raw || "").trim();
+ if (!s) {
+ return "";
+ }
+ return s.replace(/万元/g, "").replace(/万/g, "");
+}
+
+function closeCorrectionModal() {
+ const modal = document.getElementById("correction-modal");
+ modal.classList.add("hidden");
+ state.correctionContext = null;
+}
+
+function openCorrectionModal(record) {
+ const modal = document.getElementById("correction-modal");
+ const branchEl = document.getElementById("corr-branch");
+ const amountEl = document.getElementById("corr-amount");
+ const typeEl = document.getElementById("corr-type");
+ const pageEl = document.getElementById("corr-page");
+ const statusEl = document.getElementById("corr-status");
+ const noteEl = document.getElementById("corr-note");
+ const rememberEl = document.getElementById("corr-remember");
+ const keywordEl = document.getElementById("corr-keyword");
+ const keywordWrap = document.getElementById("corr-keyword-wrap");
+
+ state.correctionContext = {
+ record: { ...(record || {}) },
+ };
+ branchEl.value = record?.branch || "";
+ amountEl.value = toEditableAmount(record?.amount || "");
+ typeEl.value = record?.type || "";
+ pageEl.value = record?.page || "";
+ statusEl.value = record?.status || "";
+ noteEl.value = "";
+ rememberEl.checked = false;
+ keywordEl.value = "";
+ keywordWrap.classList.add("hidden");
+ modal.classList.remove("hidden");
+}
+
+function buildCorrectionOverrides() {
+ const ctx = state.correctionContext || {};
+ const base = ctx.record || {};
+ const branch = document.getElementById("corr-branch").value.trim();
+ const amount = normalizeAmountInput(document.getElementById("corr-amount").value);
+ const type = document.getElementById("corr-type").value.trim();
+ const page = document.getElementById("corr-page").value.trim();
+ const status = document.getElementById("corr-status").value.trim();
+
+ const out = {};
+ if (branch && branch !== (base.branch || "")) {
+ out.branch = branch;
+ }
+ if (amount && amount !== (base.amount || "")) {
+ out.amount = amount;
+ }
+ if (type && type !== (base.type || "")) {
+ out.type = type;
+ }
+ if (page && page !== (base.page || "")) {
+ out.page = page;
+ }
+ if (status && status !== (base.status || "")) {
+ out.status = status;
+ }
+ return out;
+}
+
+async function applyCorrection() {
+ const ctx = state.correctionContext || {};
+ const record = ctx.record;
+ if (!record) {
+ setMsg("缺少待修正记录", true);
+ return;
+ }
+
+ const overrides = buildCorrectionOverrides();
+ const rememberRule = Boolean(document.getElementById("corr-remember").checked);
+ const ruleKeyword = document.getElementById("corr-keyword").value.trim();
+ const note = document.getElementById("corr-note").value.trim();
+
+ setLoading(true);
+ setMsg("修正生成中...");
+ try {
+ const { data } = await postJson("/api/correction/apply", {
+ record,
+ overrides,
+ remember_rule: rememberRule,
+ rule_keyword: ruleKeyword || undefined,
+ note,
+ template_file: getTemplateFile() || undefined,
+ output_dir: getOutputDir() || undefined,
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "修正失败");
+ }
+
+ const images = Array.isArray(data.download_images) ? data.download_images : [];
+ if (images.length > 0) {
+ state.lastGeneratedImages = [...images, ...(state.lastGeneratedImages || [])];
+ renderPreview(state.lastGeneratedImages);
+ updateDownloadButtonState(false);
+ }
+ closeCorrectionModal();
+ await loadHistoryView();
+ await loadConfig();
+ if (data.rule) {
+ setMsg("修正成功并已记住规则。");
+ } else {
+ setMsg("修正成功,已生成新图片。");
+ }
+ } finally {
+ setLoading(false);
+ }
+}
+
+function appendTextCell(tr, text) {
+ const td = document.createElement("td");
+ td.textContent = text ?? "";
+ tr.appendChild(td);
+ return td;
+}
+
+function appendEmptyRow(tbody, colSpan) {
+ const tr = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = colSpan;
+ td.textContent = "暂无数据";
+ td.className = "muted";
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+}
+
+function makeMiniButton(label, className, onClick) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = `mini-btn ${className || ""}`.trim();
+ btn.textContent = label;
+ btn.addEventListener("click", onClick);
+ return btn;
+}
+
+async function markIssue(markType, sourceLine, record = {}, note = "") {
+ const line = (sourceLine || "").trim();
+ if (!line) {
+ setMsg("缺少原始行,无法标记日志", true);
+ return;
+ }
+
+ const typeText = markType === "generation_error" ? "生成错误" : "识别错误";
+
+ try {
+ const { data } = await postJson("/api/log/mark", {
+ mark_type: markType,
+ source_line: line,
+ record,
+ note,
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "标记失败");
+ }
+ setMsg(`已标记${typeText}:${line}`);
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+async function suppressSkippedItem(item) {
+ const line = String(item?.line || "").trim();
+ if (!line) {
+ setMsg("跳过项缺少原始行,无法屏蔽", true);
+ return;
+ }
+
+ try {
+ const { data } = await postJson("/api/skipped/suppress", {
+ line,
+ reason: item?.reason || "",
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "屏蔽失败");
+ }
+
+ if (state.lastResult && Array.isArray(state.lastResult.skipped)) {
+ const remain = state.lastResult.skipped.filter((x) => !isSameSkippedItem(x, item));
+ state.lastResult.skipped = remain;
+ if (state.lastResult.summary && typeof state.lastResult.summary === "object") {
+ state.lastResult.summary.skipped = remain.length;
+ renderSummary(state.lastResult.summary);
+ }
+ renderSkipped(remain);
+ document.getElementById("skip-panel").open = remain.length > 0;
+ }
+ setMsg("已屏蔽该跳过项,清空历史后会恢复。");
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+function renderSummary(summary) {
+ document.getElementById("m-input").textContent = summary?.input_lines ?? 0;
+ document.getElementById("m-parsed").textContent = summary?.parsed ?? 0;
+ document.getElementById("m-new").textContent = summary?.new ?? 0;
+ document.getElementById("m-dup").textContent = summary?.duplicate ?? 0;
+ document.getElementById("m-skip").textContent = summary?.skipped ?? 0;
+}
+
+function renderNewRecords(records) {
+ const tbody = document.getElementById("new-body");
+ tbody.innerHTML = "";
+
+ if (!records || records.length === 0) {
+ appendEmptyRow(tbody, 7);
+ return;
+ }
+
+ records.forEach((row) => {
+ const tr = document.createElement("tr");
+ appendTextCell(tr, row.branch);
+ appendTextCell(tr, row.amount);
+ appendTextCell(tr, row.type);
+ appendTextCell(tr, row.page);
+ appendTextCell(tr, row.status);
+ appendTextCell(tr, row.output_file);
+
+ const actionTd = document.createElement("td");
+ actionTd.className = "cell-actions";
+ const actions = document.createElement("div");
+ actions.className = "actions-inline";
+
+ const sourceLine = row.source_line || row.raw_text || "";
+ const baseRecord = {
+ branch: row.branch,
+ amount: row.amount,
+ type: row.type,
+ page: row.page,
+ status: row.status,
+ source_line: sourceLine,
+ raw_text: row.raw_text || "",
+ output_file: row.output_file || "",
+ };
+
+ actions.appendChild(
+ makeMiniButton("修正", "", () => {
+ openCorrectionModal(baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("识别错", "secondary", () => {
+ void markIssue("recognition_error", sourceLine, baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("生成错", "danger", () => {
+ void markIssue("generation_error", sourceLine, baseRecord);
+ })
+ );
+
+ actionTd.appendChild(actions);
+ tr.appendChild(actionTd);
+ tbody.appendChild(tr);
+ });
+}
+
+function triggerDownload(url) {
+ const a = document.createElement("a");
+ a.href = url;
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+}
+
+function isMobileClient() {
+ const ua = navigator.userAgent || "";
+ return /Android|iPhone|iPad|iPod|Mobile|HarmonyOS/i.test(ua);
+}
+
+function getSingleImageActionLabel() {
+ return isMobileClient() ? "下载" : "复制";
+}
+
+async function copyImageToClipboard(downloadUrl) {
+ if (!downloadUrl) {
+ throw new Error("缺少图片地址");
+ }
+ if (!navigator?.clipboard?.write || typeof window.ClipboardItem === "undefined") {
+ throw new Error("当前浏览器不支持图片复制");
+ }
+
+ const res = await fetch(toInlineUrl(downloadUrl), { cache: "no-store" });
+ if (!res.ok) {
+ throw new Error("获取图片失败");
+ }
+ const blob = await res.blob();
+ const contentType = blob.type || "image/png";
+ const item = new window.ClipboardItem({ [contentType]: blob });
+ await navigator.clipboard.write([item]);
+}
+
+async function handleSingleImageAction(downloadUrl) {
+ if (isMobileClient()) {
+ triggerDownload(downloadUrl);
+ return;
+ }
+ try {
+ await copyImageToClipboard(downloadUrl);
+ setMsg("复制成功");
+ } catch (err) {
+ setMsg(`复制失败:${err?.message || "请检查浏览器权限"}`, true);
+ }
+}
+
+function renderDuplicateRecords(records) {
+ const tbody = document.getElementById("dup-body");
+ tbody.innerHTML = "";
+
+ if (!records || records.length === 0) {
+ appendEmptyRow(tbody, 6);
+ return;
+ }
+
+ records.forEach((row) => {
+ const tr = document.createElement("tr");
+ appendTextCell(tr, row.branch);
+ appendTextCell(tr, row.amount);
+ appendTextCell(tr, row.type);
+ appendTextCell(tr, row.status);
+ appendTextCell(tr, translateDupReason(row.duplicate_reason));
+
+ const actionTd = document.createElement("td");
+ actionTd.className = "cell-actions";
+ const actions = document.createElement("div");
+ actions.className = "actions-inline";
+
+ if (row.download_url) {
+ actions.appendChild(
+ makeMiniButton("预览", "secondary", () => {
+ window.open(toInlineUrl(row.download_url), "_blank", "noopener,noreferrer");
+ })
+ );
+ actions.appendChild(
+ makeMiniButton(getSingleImageActionLabel(), "secondary", () => {
+ void handleSingleImageAction(row.download_url);
+ })
+ );
+ } else {
+ const muted = document.createElement("span");
+ muted.className = "muted";
+ muted.textContent = "暂无图片";
+ actions.appendChild(muted);
+ }
+
+ const sourceLine = row.source_line || row.raw_text || "";
+ const baseRecord = {
+ branch: row.branch,
+ amount: row.amount,
+ type: row.type,
+ page: row.page,
+ status: row.status,
+ source_line: sourceLine,
+ raw_text: row.raw_text || "",
+ output_file: row.output_file || "",
+ duplicate_reason: row.duplicate_reason,
+ };
+
+ actions.appendChild(
+ makeMiniButton("修正", "", () => {
+ openCorrectionModal(baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("识别错", "secondary", () => {
+ void markIssue("recognition_error", sourceLine, baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("生成错", "danger", () => {
+ void markIssue("generation_error", sourceLine, baseRecord);
+ })
+ );
+
+ actionTd.appendChild(actions);
+ tr.appendChild(actionTd);
+ tbody.appendChild(tr);
+ });
+}
+
+function renderHistoryRecords(items, totalCount = 0) {
+ const tbody = document.getElementById("history-body");
+ const note = document.getElementById("history-note");
+ if (!tbody || !note) {
+ return;
+ }
+
+ tbody.innerHTML = "";
+ const rows = Array.isArray(items) ? items : [];
+ note.textContent = `总计 ${totalCount} 条,当前显示 ${rows.length} 条`;
+
+ if (rows.length === 0) {
+ appendEmptyRow(tbody, 7);
+ return;
+ }
+
+ rows.forEach((row) => {
+ const tr = document.createElement("tr");
+ appendTextCell(tr, row.created_at || "");
+ appendTextCell(tr, row.branch || "");
+ appendTextCell(tr, row.amount || "");
+ appendTextCell(tr, row.type || "");
+ appendTextCell(tr, row.status || "");
+ appendTextCell(tr, row.source_line || row.raw_text || "");
+
+ const actionTd = document.createElement("td");
+ actionTd.className = "cell-actions";
+ const actions = document.createElement("div");
+ actions.className = "actions-inline";
+
+ if (row.download_url) {
+ actions.appendChild(
+ makeMiniButton("预览", "secondary", () => {
+ window.open(toInlineUrl(row.download_url), "_blank", "noopener,noreferrer");
+ })
+ );
+ actions.appendChild(
+ makeMiniButton(getSingleImageActionLabel(), "secondary", () => {
+ void handleSingleImageAction(row.download_url);
+ })
+ );
+ } else {
+ const muted = document.createElement("span");
+ muted.className = "muted";
+ muted.textContent = "图片已清理";
+ actions.appendChild(muted);
+ }
+
+ const sourceLine = row.source_line || row.raw_text || "";
+ const baseRecord = {
+ branch: row.branch,
+ amount: row.amount,
+ type: row.type,
+ page: row.page,
+ status: row.status,
+ source_line: sourceLine,
+ raw_text: row.raw_text || "",
+ output_file: row.output_file || "",
+ created_at: row.created_at,
+ };
+ actions.appendChild(
+ makeMiniButton("修正", "", () => {
+ openCorrectionModal(baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("识别错", "secondary", () => {
+ void markIssue("recognition_error", sourceLine, baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("生成错", "danger", () => {
+ void markIssue("generation_error", sourceLine, baseRecord);
+ })
+ );
+
+ actionTd.appendChild(actions);
+ tr.appendChild(actionTd);
+ tbody.appendChild(tr);
+ });
+}
+
+async function loadHistoryView(showMsg = false) {
+ const res = await fetch("/api/history/view?limit=500");
+ let data = {};
+ try {
+ data = await res.json();
+ } catch (e) {
+ throw new Error("历史接口返回异常");
+ }
+ if (!data.ok) {
+ throw new Error(data.error || "加载历史失败");
+ }
+ renderHistoryRecords(data.items || [], data.count || 0);
+ if (showMsg) {
+ setMsg(`历史已刷新:共 ${data.count || 0} 条。`);
+ }
+}
+
+function renderSkipped(skipped) {
+ const ul = document.getElementById("skip-list");
+ ul.innerHTML = "";
+
+ if (!skipped || skipped.length === 0) {
+ const li = document.createElement("li");
+ li.textContent = "无跳过项";
+ li.className = "muted";
+ ul.appendChild(li);
+ return;
+ }
+
+ skipped.forEach((item) => {
+ const li = document.createElement("li");
+
+ const main = document.createElement("span");
+ const reason = document.createElement("strong");
+ reason.textContent = translateSkipReason(item.reason);
+ main.appendChild(reason);
+ main.appendChild(document.createTextNode(`:${item.line || ""}`));
+
+ const actions = document.createElement("span");
+ actions.className = "skip-actions";
+ actions.appendChild(
+ makeMiniButton("屏蔽", "danger", () => {
+ void suppressSkippedItem(item);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("标记识别错", "secondary", () => {
+ void markIssue("recognition_error", item.line || "", {
+ reason: item.reason,
+ stage: "skipped",
+ });
+ })
+ );
+
+ li.appendChild(main);
+ li.appendChild(actions);
+ ul.appendChild(li);
+ });
+}
+
+function renderPreview(items) {
+ const grid = document.getElementById("preview-grid");
+ const note = document.getElementById("preview-note");
+ grid.innerHTML = "";
+
+ if (!items || items.length === 0) {
+ note.textContent = "生成后显示";
+ const empty = document.createElement("div");
+ empty.className = "preview-empty muted";
+ empty.textContent = "暂无预览图";
+ grid.appendChild(empty);
+ return;
+ }
+
+ note.textContent = `共 ${items.length} 张`;
+
+ items.forEach((item) => {
+ const card = document.createElement("article");
+ card.className = "preview-item";
+
+ const img = document.createElement("img");
+ img.className = "preview-image";
+ img.loading = "lazy";
+ img.alt = item.name || "预览图";
+ img.src = toInlineUrl(item.download_url);
+ img.addEventListener("click", () => {
+ window.open(toInlineUrl(item.download_url), "_blank", "noopener,noreferrer");
+ });
+
+ const bar = document.createElement("div");
+ bar.className = "preview-bar";
+
+ const name = document.createElement("span");
+ name.className = "preview-name";
+ name.textContent = item.name || "未命名图片";
+
+ const btn = document.createElement("button");
+ btn.className = "preview-btn secondary";
+ btn.type = "button";
+ btn.textContent = getSingleImageActionLabel();
+ btn.addEventListener("click", () => {
+ void handleSingleImageAction(item.download_url);
+ });
+
+ bar.appendChild(name);
+ bar.appendChild(btn);
+ card.appendChild(img);
+ card.appendChild(bar);
+ grid.appendChild(card);
+ });
+}
+
+function renderResult(result) {
+ state.lastResult = result;
+ state.keyFields = result?.dedup_key_fields || ["branch", "amount", "type"];
+
+ renderSummary(result.summary || {});
+ renderNewRecords(result.new_records || []);
+ renderDuplicateRecords(result.duplicate_records || []);
+ renderSkipped(result.skipped || []);
+
+ document.getElementById("dup-panel").open = (result.duplicate_records || []).length > 0;
+ document.getElementById("skip-panel").open = (result.skipped || []).length > 0;
+}
+
+function updateDownloadButtonState(loading = false) {
+ const btn = document.getElementById("download-btn");
+ if (!btn) {
+ return;
+ }
+ const hasImages = Array.isArray(state.lastGeneratedImages) && state.lastGeneratedImages.length > 0;
+ btn.disabled = loading || !hasImages;
+}
+
+function setLoading(loading) {
+ document.getElementById("generate-btn").disabled = loading;
+ document.getElementById("parse-btn").disabled = loading;
+ document.getElementById("force-clear-btn").disabled = loading;
+ document.getElementById("history-refresh-btn").disabled = loading;
+ document.getElementById("clear-btn").disabled = loading;
+ const corrSubmit = document.getElementById("corr-submit");
+ const corrCancel = document.getElementById("corr-cancel");
+ if (corrSubmit) {
+ corrSubmit.disabled = loading;
+ }
+ if (corrCancel) {
+ corrCancel.disabled = loading;
+ }
+ updateDownloadButtonState(loading);
+}
+
+async function triggerMultiDownloads(items) {
+ if (!Array.isArray(items)) {
+ return { total: 0, started: 0, blocked: 0, mobile: false };
+ }
+ const validItems = items.filter((x) => x && x.download_url);
+ if (validItems.length === 0) {
+ return { total: 0, started: 0, blocked: 0, mobile: isMobileClient() };
+ }
+
+ if (isMobileClient()) {
+ let started = 0;
+ let blocked = 0;
+ validItems.forEach((it) => {
+ const win = window.open(it.download_url, "_blank", "noopener,noreferrer");
+ if (win) {
+ started += 1;
+ } else {
+ blocked += 1;
+ }
+ });
+ return { total: validItems.length, started, blocked, mobile: true };
+ }
+
+ let started = 0;
+ for (let i = 0; i < validItems.length; i += 1) {
+ triggerDownload(validItems[i].download_url);
+ started += 1;
+ await new Promise((resolve) => setTimeout(resolve, 180));
+ }
+ return { total: validItems.length, started, blocked: 0, mobile: false };
+}
+
+function askInsuranceYear() {
+ return new Promise((resolve) => {
+ const modal = document.getElementById("insurance-modal");
+ const btn3 = document.getElementById("insurance-3");
+ const btn5 = document.getElementById("insurance-5");
+ const btnCancel = document.getElementById("insurance-cancel");
+
+ const cleanup = () => {
+ modal.classList.add("hidden");
+ btn3.onclick = null;
+ btn5.onclick = null;
+ btnCancel.onclick = null;
+ };
+
+ btn3.onclick = () => {
+ cleanup();
+ resolve("3");
+ };
+
+ btn5.onclick = () => {
+ cleanup();
+ resolve("5");
+ };
+
+ btnCancel.onclick = () => {
+ cleanup();
+ resolve(null);
+ };
+
+ modal.classList.remove("hidden");
+ });
+}
+
+async function loadConfig() {
+ const res = await fetch("/api/config");
+ const data = await res.json();
+ if (!data.ok) {
+ throw new Error(data.error || "加载配置失败");
+ }
+
+ const c = data.config;
+ const div = document.getElementById("config-summary");
+ div.innerHTML = [
+ `
模板文件:${c.template_file || "(未配置)"}
`,
+ `输出目录:${c.output_dir || "(未配置)"}
`,
+ `触发关键词:${c.trigger_keyword || "#接龙"}
`,
+ `历史条数:${data.history_count}
`,
+ `今日日志:${data.review_log_count || 0}
`,
+ ].join("");
+
+ if (!getTemplateFile()) {
+ document.getElementById("template-file").value = c.template_file || "";
+ }
+ if (!getOutputDir()) {
+ document.getElementById("output-dir").value = c.output_dir || "";
+ }
+}
+
+async function parseOnly(insuranceYear = null) {
+ const rawText = getRawText();
+ if (!rawText) {
+ setMsg("请先输入接龙文本", true);
+ return;
+ }
+
+ setMsg("解析中...");
+ setLoading(true);
+ try {
+ const { data } = await postJson("/api/parse", {
+ raw_text: rawText,
+ insurance_year: insuranceYear,
+ });
+
+ if (!data.ok) {
+ throw new Error(data.error || "解析失败");
+ }
+
+ renderResult(data.result);
+ if (data.result.needs_insurance_choice) {
+ setMsg("检测到保险未写年限,生成时会要求选择3年交或5年交。", false);
+ } else {
+ setMsg(
+ `完成:有效 ${data.result.summary.parsed} 条,新增 ${data.result.summary.new} 条,重复 ${data.result.summary.duplicate} 条。`
+ );
+ }
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function generateOnly() {
+ const rawText = getRawText();
+ if (!rawText) {
+ setMsg("请先输入接龙文本", true);
+ return;
+ }
+
+ const templateFile = getTemplateFile();
+ const outputDir = getOutputDir();
+ const progressToken = `${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
+
+ setMsg("生成中...");
+ setProgressVisible(true);
+ renderProgress({
+ stage: "提交任务",
+ percent: 1,
+ detail: "请求已发出",
+ status: "running",
+ });
+ startProgressPolling(progressToken);
+ void fetchProgressOnce(progressToken);
+ setLoading(true);
+
+ let insuranceYear = null;
+
+ try {
+ for (let i = 0; i < 2; i += 1) {
+ const { status, data } = await postJson("/api/generate", {
+ raw_text: rawText,
+ insurance_year: insuranceYear,
+ progress_token: progressToken,
+ template_file: templateFile || undefined,
+ output_dir: outputDir || undefined,
+ save_history: true,
+ });
+ if (data.progress_token) {
+ state.activeProgressToken = data.progress_token;
+ }
+ await fetchProgressOnce(state.activeProgressToken || progressToken);
+
+ if (data.ok) {
+ if (data.result) {
+ renderResult(data.result);
+ }
+
+ if (data.generated_count > 0) {
+ const downloadImages = data.download_images || [];
+ state.lastGeneratedImages = downloadImages;
+ renderPreview(downloadImages);
+ updateDownloadButtonState(false);
+ setMsg(`生成完成:${data.generated_count} 张,可点击“下载全部”按钮下载。`);
+ } else {
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ setMsg(data.message || "没有可生成的新记录");
+ }
+ renderProgress({
+ stage: "完成",
+ percent: 100,
+ detail: `生成结束,产出 ${data.generated_count || 0} 张`,
+ status: "done",
+ });
+ stopProgressPolling();
+
+ await loadConfig();
+ await loadHistoryView();
+ return;
+ }
+
+ if (status === 400 && data.error_code === "insurance_year_required") {
+ renderProgress({
+ stage: "等待选择",
+ percent: 15,
+ detail: "保险年限待选择(3年交/5年交)",
+ status: "need_input",
+ });
+ if (data.result) {
+ renderResult(data.result);
+ }
+ const selected = await askInsuranceYear();
+ if (!selected) {
+ setMsg("你已取消保险年限选择,本次未生成。", true);
+ return;
+ }
+ insuranceYear = selected;
+ continue;
+ }
+
+ throw new Error(data.error || data.message || "生成失败");
+ }
+
+ throw new Error("保险年限选择后仍未生成,请重试");
+ } catch (err) {
+ renderProgress({
+ stage: "失败",
+ percent: 100,
+ detail: err?.message || String(err),
+ status: "error",
+ error: err?.message || String(err),
+ });
+ stopProgressPolling();
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function downloadAllGenerated() {
+ const items = state.lastGeneratedImages || [];
+ if (!Array.isArray(items) || items.length === 0) {
+ setMsg("暂无可下载图片,请先生成。", true);
+ return;
+ }
+
+ setMsg(`开始下载:${items.length} 张...`);
+ setLoading(true);
+ try {
+ const stat = await triggerMultiDownloads(items);
+ if (stat.mobile) {
+ if (stat.blocked > 0) {
+ setMsg(
+ `移动端已触发 ${stat.started}/${stat.total} 张下载。若未全部下载,请允许弹窗后重试,或在预览区逐张下载。`,
+ true
+ );
+ } else {
+ setMsg(`移动端已触发 ${stat.started} 张下载。`);
+ }
+ } else {
+ setMsg(`下载完成:${stat.started} 张。`);
+ }
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function clearHistory() {
+ const ok = window.confirm("确认清空历史记录?");
+ if (!ok) {
+ return;
+ }
+
+ setLoading(true);
+ setMsg("清空中...");
+ try {
+ const { data } = await postJson("/api/history/clear", {});
+ if (!data.ok) {
+ throw new Error(data.error || "清空失败");
+ }
+ setMsg("历史已清空");
+ await loadConfig();
+ await loadHistoryView();
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function forceClearOutput() {
+ const ok = window.confirm("确认强制清理已生成截图与任务文件?这不会清空历史记录。");
+ if (!ok) {
+ return;
+ }
+
+ setLoading(true);
+ setMsg("清理截图中...");
+ try {
+ const { data } = await postJson("/api/output/clear", {});
+ if (!data.ok) {
+ throw new Error(data.error || "清理失败");
+ }
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ await loadHistoryView();
+ setProgressVisible(false);
+ stopProgressPolling();
+ setMsg(
+ `清理完成:删除任务目录 ${data.removed_dirs || 0} 个,删除文件 ${data.removed_files || 0} 个。`
+ );
+ } finally {
+ setLoading(false);
+ }
+}
+
+function bindEvents() {
+ document.getElementById("parse-btn").addEventListener("click", async () => {
+ try {
+ await parseOnly();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("generate-btn").addEventListener("click", async () => {
+ try {
+ await generateOnly();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("download-btn").addEventListener("click", async () => {
+ try {
+ await downloadAllGenerated();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("force-clear-btn").addEventListener("click", async () => {
+ try {
+ await forceClearOutput();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("history-refresh-btn").addEventListener("click", async () => {
+ try {
+ await loadHistoryView(true);
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("clear-btn").addEventListener("click", async () => {
+ try {
+ await clearHistory();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ const corrModal = document.getElementById("correction-modal");
+ const corrRemember = document.getElementById("corr-remember");
+ const corrKeywordWrap = document.getElementById("corr-keyword-wrap");
+
+ document.getElementById("corr-cancel").addEventListener("click", () => {
+ closeCorrectionModal();
+ });
+ document.getElementById("corr-submit").addEventListener("click", async () => {
+ try {
+ await applyCorrection();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+ corrRemember.addEventListener("change", () => {
+ corrKeywordWrap.classList.toggle("hidden", !corrRemember.checked);
+ });
+ corrModal.addEventListener("click", (ev) => {
+ if (ev.target === corrModal) {
+ closeCorrectionModal();
+ }
+ });
+}
+
+async function init() {
+ bindEvents();
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ try {
+ await loadConfig();
+ await loadHistoryView();
+ setMsg("可直接粘贴接龙并生成。", false);
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+init();
diff --git a/app/config/xibao_logic.json b/app/config/xibao_logic.json
new file mode 100644
index 0000000..e6b7879
--- /dev/null
+++ b/app/config/xibao_logic.json
@@ -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"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/package-lock.json b/app/package-lock.json
new file mode 100644
index 0000000..4356e50
--- /dev/null
+++ b/app/package-lock.json
@@ -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
+ }
+ }
+ }
+ }
+}
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000..5e8b418
--- /dev/null
+++ b/app/package.json
@@ -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"
+ }
+}
diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/repositories/history_repository.py b/app/repositories/history_repository.py
new file mode 100644
index 0000000..7003337
--- /dev/null
+++ b/app/repositories/history_repository.py
@@ -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())
diff --git a/app/requirements.txt b/app/requirements.txt
new file mode 100644
index 0000000..ff4402d
--- /dev/null
+++ b/app/requirements.txt
@@ -0,0 +1 @@
+python-pptx==1.0.2
diff --git a/app/scripts/bootstrap.sh b/app/scripts/bootstrap.sh
new file mode 100755
index 0000000..01917e9
--- /dev/null
+++ b/app/scripts/bootstrap.sh
@@ -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."
diff --git a/app/scripts/smoke_api.sh b/app/scripts/smoke_api.sh
new file mode 100755
index 0000000..3c05ee0
--- /dev/null
+++ b/app/scripts/smoke_api.sh
@@ -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"
diff --git a/app/scripts/sync_vue_vendor.sh b/app/scripts/sync_vue_vendor.sh
new file mode 100755
index 0000000..b87c8ea
--- /dev/null
+++ b/app/scripts/sync_vue_vendor.sh
@@ -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"
diff --git a/app/server.py b/app/server.py
new file mode 100644
index 0000000..1c3d866
--- /dev/null
+++ b/app/server.py
@@ -0,0 +1,3348 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import ctypes
+import gc
+import hashlib
+import io
+import json
+import os
+import re
+import shutil
+import subprocess
+import threading
+import time
+import uuid
+from datetime import datetime, timedelta
+from http import HTTPStatus
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any, Callable
+from urllib.parse import quote, urlparse
+
+from pptx.enum.text import PP_ALIGN
+from pptx import Presentation
+from api_get_routes import handle_get_routes
+from api_post_routes import handle_post_routes
+
+BASE_DIR = Path(__file__).resolve().parent
+STATIC_DIR = BASE_DIR / "static"
+CONFIG_PATH = BASE_DIR / "config" / "xibao_logic.json"
+DATA_DIR = BASE_DIR / "data"
+DEFAULT_HISTORY_PATH = DATA_DIR / "generated_history.json"
+DEFAULT_OUTPUT_DIR = BASE_DIR / "output"
+DEFAULT_TEMPLATE_FALLBACK = BASE_DIR.parent / "黄金三十天喜报模版(余额、保险、理财)(1).pptx"
+REVIEW_LOG_DIR = DATA_DIR / "review_logs"
+MANUAL_RULES_PATH = DATA_DIR / "manual_rules.json"
+ISSUE_MARKS_PATH = DATA_DIR / "marked_issues.json"
+SKIPPED_SUPPRESS_PATH = DATA_DIR / "skipped_suppressions.json"
+CONVERTER_IMAGE = "minidocks/libreoffice"
+LOCAL_LIBREOFFICE_BIN = shutil.which("libreoffice") or shutil.which("soffice")
+LOCAL_PDFTOPPM_BIN = shutil.which("pdftoppm")
+
+DATA_DIR.mkdir(parents=True, exist_ok=True)
+DEFAULT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+REVIEW_LOG_DIR.mkdir(parents=True, exist_ok=True)
+_HISTORY_LOCK = threading.Lock()
+_DOWNLOAD_LOCK = threading.Lock()
+_CONVERTER_LOCK = threading.Lock()
+_LOG_LOCK = threading.Lock()
+_PROGRESS_LOCK = threading.Lock()
+_RULES_LOCK = threading.Lock()
+_ISSUE_LOCK = threading.Lock()
+_SKIP_SUPPRESS_LOCK = threading.Lock()
+_ACTIVE_JOB_LOCK = threading.Lock()
+_GENERATE_SLOT_LOCK = threading.Lock()
+_GENERATE_STATE_LOCK = threading.Lock()
+DOWNLOAD_CACHE: dict[str, dict[str, Any]] = {}
+GEN_PROGRESS: dict[str, dict[str, Any]] = {}
+ACTIVE_JOB_DIRS: set[str] = set()
+ACTIVE_GENERATION: dict[str, Any] = {"token": "", "started_at": 0.0}
+CONVERTER_IMAGE_READY = False
+_MALLOC_TRIM_FN: Callable[[int], int] | None = None
+
+TERM_DEFS = [
+ {
+ "type": "三个月定期",
+ "page": "page_0",
+ "regex": re.compile(r"(?:三|3)\s*个?月"),
+ },
+ {
+ "type": "六个月定期",
+ "page": "page_1",
+ "regex": re.compile(r"(?:六|6)\s*个?月|半年"),
+ },
+ {
+ "type": "一年期定期",
+ "page": "page_2",
+ "regex": re.compile(r"(?:定期)?\s*(?:存\s*)?(?:一|1)\s*年(?:期)?|定期一年|存1年"),
+ },
+]
+
+CHINESE_DIGIT_MAP = {
+ "零": 0,
+ "〇": 0,
+ "一": 1,
+ "二": 2,
+ "两": 2,
+ "三": 3,
+ "四": 4,
+ "五": 5,
+ "六": 6,
+ "七": 7,
+ "八": 8,
+ "九": 9,
+ "壹": 1,
+ "贰": 2,
+ "叁": 3,
+ "肆": 4,
+ "伍": 5,
+ "陆": 6,
+ "柒": 7,
+ "捌": 8,
+ "玖": 9,
+}
+CHINESE_UNIT_MAP = {"十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000}
+CHINESE_NUMBER_RE = re.compile(r"[零〇一二两三四五六七八九十百千万亿壹贰叁肆伍陆柒捌玖拾佰仟点點]+")
+STATUS_HEURISTIC_PATTERNS: dict[str, list[re.Pattern[str]]] = {
+ "揽收现金": [
+ re.compile(r"揽收[^,。,;;\n]{0,24}现金"),
+ ],
+ "揽收彩礼": [
+ re.compile(r"揽收[^,。,;;\n]{0,24}(?:彩礼|礼金)"),
+ re.compile(r"(?:彩礼|礼金)[^,。,;;\n]{0,24}揽收"),
+ ],
+ "微信提现": [
+ re.compile(r"(?:微信|支付宝)[^,。,;;\n]{0,16}提现"),
+ re.compile(r"提现[^,。,;;\n]{0,16}(?:微信|支付宝)"),
+ ],
+ "商户提现": [
+ re.compile(r"商户[^,。,;;\n]{0,16}提现"),
+ re.compile(r"提现[^,。,;;\n]{0,16}商户"),
+ ],
+ "揽收商户": [
+ re.compile(r"揽收[^,。,;;\n]{0,16}商户"),
+ re.compile(r"商户[^,。,;;\n]{0,16}揽收"),
+ ],
+ "揽收他行": [
+ re.compile(r"揽收[^,。,;;\n]{0,20}他行"),
+ re.compile(r"他行[^,。,;;\n]{0,20}揽收"),
+ ],
+ "他行挖转": [
+ re.compile(r"挖转[^,。,;;\n]{0,20}他行"),
+ re.compile(r"他行[^,。,;;\n]{0,20}挖转"),
+ re.compile(r"挖他行"),
+ ],
+}
+
+
+class ConfigError(RuntimeError):
+ pass
+
+
+def now_ts() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
+
+
+def load_config() -> dict[str, Any]:
+ if not CONFIG_PATH.exists():
+ raise ConfigError(f"config file not found: {CONFIG_PATH}")
+
+ with CONFIG_PATH.open("r", encoding="utf-8") as f:
+ config = json.load(f)
+
+ if not isinstance(config, dict):
+ raise ConfigError("config root must be a JSON object")
+
+ return config
+
+
+def is_windows_path(path_text: str) -> bool:
+ return bool(re.match(r"^[A-Za-z]:\\", path_text))
+
+
+def resolve_history_path(config: dict[str, Any]) -> Path:
+ history_file = str(config.get("history_file", "")).strip()
+ if history_file and not is_windows_path(history_file):
+ p = Path(history_file)
+ if not p.is_absolute():
+ p = BASE_DIR / p
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+ DEFAULT_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
+ return DEFAULT_HISTORY_PATH
+
+
+def resolve_template_path(config: dict[str, Any], override_path: str | None = None) -> Path:
+ candidates: list[Path] = []
+
+ if override_path:
+ p = Path(override_path)
+ if not p.is_absolute():
+ p = BASE_DIR / p
+ candidates.append(p)
+
+ cfg_template = str(config.get("template_file", "")).strip()
+ if cfg_template and not is_windows_path(cfg_template):
+ p = Path(cfg_template)
+ if not p.is_absolute():
+ p = BASE_DIR / p
+ candidates.append(p)
+
+ env_template = os.environ.get("XIBAO_TEMPLATE_FILE", "").strip()
+ if env_template:
+ p = Path(env_template)
+ if not p.is_absolute():
+ p = BASE_DIR / p
+ candidates.append(p)
+
+ candidates.append(DEFAULT_TEMPLATE_FALLBACK)
+
+ for p in candidates:
+ if p.exists() and p.is_file():
+ return p
+
+ raise ConfigError(
+ "template file not found. Set config.template_file to a valid local path "
+ "or pass template_file in request body."
+ )
+
+
+def resolve_output_dir(config: dict[str, Any], override_path: str | None = None) -> Path:
+ if override_path:
+ p = Path(override_path)
+ if not p.is_absolute():
+ p = BASE_DIR / p
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+
+ output_dir = str(config.get("output_dir", "")).strip()
+ if output_dir and not is_windows_path(output_dir):
+ p = Path(output_dir)
+ if not p.is_absolute():
+ p = BASE_DIR / p
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+
+ DEFAULT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ return DEFAULT_OUTPUT_DIR
+
+
+def load_history(history_path: Path) -> list[dict[str, Any]]:
+ if not history_path.exists():
+ return []
+
+ with history_path.open("r", encoding="utf-8") as f:
+ raw = json.load(f)
+
+ if isinstance(raw, list):
+ return [r for r in raw if isinstance(r, dict)]
+
+ if isinstance(raw, dict) and isinstance(raw.get("records"), list):
+ return [r for r in raw["records"] if isinstance(r, dict)]
+
+ return []
+
+
+def save_history(history_path: Path, records: list[dict[str, Any]]) -> None:
+ with history_path.open("w", encoding="utf-8") as f:
+ json.dump(records, f, ensure_ascii=False, indent=2)
+
+
+def load_manual_rules() -> list[dict[str, Any]]:
+ if not MANUAL_RULES_PATH.exists():
+ return []
+ with _RULES_LOCK:
+ try:
+ with MANUAL_RULES_PATH.open("r", encoding="utf-8") as f:
+ raw = json.load(f)
+ except Exception:
+ return []
+ if not isinstance(raw, list):
+ return []
+ return [x for x in raw if isinstance(x, dict)]
+
+
+def save_manual_rules(rules: list[dict[str, Any]]) -> None:
+ with _RULES_LOCK:
+ with MANUAL_RULES_PATH.open("w", encoding="utf-8") as f:
+ json.dump(rules, f, ensure_ascii=False, indent=2)
+
+
+def _load_issue_marks_unlocked() -> list[dict[str, Any]]:
+ if not ISSUE_MARKS_PATH.exists():
+ return []
+ try:
+ with ISSUE_MARKS_PATH.open("r", encoding="utf-8") as f:
+ raw = json.load(f)
+ except Exception:
+ return []
+ if not isinstance(raw, list):
+ return []
+ return [x for x in raw if isinstance(x, dict)]
+
+
+def _save_issue_marks_unlocked(items: list[dict[str, Any]]) -> None:
+ ISSUE_MARKS_PATH.parent.mkdir(parents=True, exist_ok=True)
+ with ISSUE_MARKS_PATH.open("w", encoding="utf-8") as f:
+ json.dump(items, f, ensure_ascii=False, indent=2)
+
+
+def list_issue_marks(status: str = "active", limit: int = 500) -> tuple[list[dict[str, Any]], int]:
+ status_norm = str(status or "active").strip().lower()
+ if status_norm not in {"active", "resolved", "all"}:
+ status_norm = "active"
+ limit = max(1, min(2000, int(limit)))
+
+ with _ISSUE_LOCK:
+ items = _load_issue_marks_unlocked()
+
+ normalized: list[dict[str, Any]] = []
+ for item in items:
+ obj = dict(item)
+ item_status = str(obj.get("status", "active")).strip().lower()
+ if item_status not in {"active", "resolved"}:
+ item_status = "active"
+ obj["status"] = item_status
+ normalized.append(obj)
+
+ if status_norm == "all":
+ filtered = normalized
+ else:
+ filtered = [x for x in normalized if str(x.get("status", "active")) == status_norm]
+
+ filtered.sort(
+ key=lambda x: str(x.get("updated_at") or x.get("created_at") or ""),
+ reverse=True,
+ )
+ total = len(filtered)
+ return filtered[:limit], total
+
+
+def upsert_issue_mark(
+ *,
+ mark_type: str,
+ source_line: str,
+ note: str = "",
+ record: dict[str, Any] | None = None,
+) -> tuple[dict[str, Any], bool]:
+ mark_type_text = str(mark_type).strip()
+ if mark_type_text not in {"recognition_error", "generation_error"}:
+ raise ValueError("mark_type must be recognition_error or generation_error")
+
+ line_text = str(source_line).strip()
+ if not line_text:
+ raise ValueError("source_line is required")
+
+ now = datetime.now().isoformat(timespec="seconds")
+ record_obj = dict(record) if isinstance(record, dict) else {}
+ note_text = str(note).strip()
+
+ with _ISSUE_LOCK:
+ items = _load_issue_marks_unlocked()
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ if str(item.get("status", "active")).strip().lower() != "active":
+ continue
+ if str(item.get("mark_type", "")).strip() != mark_type_text:
+ continue
+ if str(item.get("source_line", "")).strip() != line_text:
+ continue
+ item["note"] = note_text
+ item["record"] = record_obj
+ item["updated_at"] = now
+ _save_issue_marks_unlocked(items)
+ return dict(item), False
+
+ issue = {
+ "id": uuid.uuid4().hex[:12],
+ "mark_type": mark_type_text,
+ "source_line": line_text,
+ "note": note_text,
+ "record": record_obj,
+ "status": "active",
+ "created_at": now,
+ "updated_at": now,
+ }
+ items.append(issue)
+ _save_issue_marks_unlocked(items)
+ return dict(issue), True
+
+
+def update_issue_mark(
+ *,
+ issue_id: str,
+ mark_type: str | None = None,
+ source_line: str | None = None,
+ note: str | None = None,
+ record: dict[str, Any] | None = None,
+) -> dict[str, Any] | None:
+ issue_id_text = str(issue_id).strip()
+ if not issue_id_text:
+ raise ValueError("id is required")
+
+ now = datetime.now().isoformat(timespec="seconds")
+ with _ISSUE_LOCK:
+ items = _load_issue_marks_unlocked()
+ target = None
+ for item in items:
+ if str(item.get("id", "")).strip() == issue_id_text:
+ target = item
+ break
+ if target is None:
+ return None
+
+ if mark_type is not None:
+ mark_type_text = str(mark_type).strip()
+ if mark_type_text not in {"recognition_error", "generation_error"}:
+ raise ValueError("mark_type must be recognition_error or generation_error")
+ target["mark_type"] = mark_type_text
+
+ if source_line is not None:
+ source_line_text = str(source_line).strip()
+ if not source_line_text:
+ raise ValueError("source_line is required")
+ target["source_line"] = source_line_text
+
+ if note is not None:
+ target["note"] = str(note).strip()
+
+ if record is not None:
+ target["record"] = dict(record) if isinstance(record, dict) else {}
+
+ target["updated_at"] = now
+ _save_issue_marks_unlocked(items)
+ return dict(target)
+
+
+def delete_issue_mark(issue_id: str) -> bool:
+ issue_id_text = str(issue_id).strip()
+ if not issue_id_text:
+ raise ValueError("id is required")
+
+ with _ISSUE_LOCK:
+ items = _load_issue_marks_unlocked()
+ remain: list[dict[str, Any]] = []
+ deleted = False
+ for item in items:
+ if str(item.get("id", "")).strip() == issue_id_text:
+ deleted = True
+ continue
+ remain.append(item)
+ if deleted:
+ _save_issue_marks_unlocked(remain)
+ return deleted
+
+
+def resolve_issue_marks_by_source_line(source_line: str, reason: str = "") -> dict[str, Any]:
+ line_text = str(source_line).strip()
+ if not line_text:
+ return {"count": 0, "ids": []}
+
+ now = datetime.now().isoformat(timespec="seconds")
+ resolved_ids: list[str] = []
+ with _ISSUE_LOCK:
+ items = _load_issue_marks_unlocked()
+ changed = False
+ for item in items:
+ if str(item.get("status", "active")).strip().lower() != "active":
+ continue
+ if str(item.get("source_line", "")).strip() != line_text:
+ continue
+ item["status"] = "resolved"
+ item["resolved_at"] = now
+ item["updated_at"] = now
+ if reason:
+ item["resolved_reason"] = reason
+ resolved_ids.append(str(item.get("id", "")).strip())
+ changed = True
+ if changed:
+ _save_issue_marks_unlocked(items)
+ return {"count": len([x for x in resolved_ids if x]), "ids": [x for x in resolved_ids if x]}
+
+
+def normalize_skip_line(line: str) -> str:
+ return re.sub(r"\s+", "", str(line or "").strip())
+
+
+def skip_suppression_key(line: str, reason: str = "") -> str:
+ normalized_line = normalize_skip_line(line)
+ normalized_reason = str(reason or "").strip()
+ if not normalized_line:
+ return ""
+ return f"{normalized_reason}|{normalized_line}"
+
+
+def skip_suppression_keys_for_item(line: str, reason: str = "") -> list[str]:
+ normalized_line = normalize_skip_line(line)
+ if not normalized_line:
+ return []
+ normalized_reason = str(reason or "").strip()
+ keys = [f"*|{normalized_line}"]
+ if normalized_reason:
+ keys.insert(0, f"{normalized_reason}|{normalized_line}")
+ return keys
+
+
+def _load_skip_suppressions_unlocked() -> list[dict[str, Any]]:
+ if not SKIPPED_SUPPRESS_PATH.exists():
+ return []
+ try:
+ with SKIPPED_SUPPRESS_PATH.open("r", encoding="utf-8") as f:
+ raw = json.load(f)
+ except Exception:
+ return []
+ if not isinstance(raw, list):
+ return []
+ return [x for x in raw if isinstance(x, dict)]
+
+
+def _save_skip_suppressions_unlocked(items: list[dict[str, Any]]) -> None:
+ SKIPPED_SUPPRESS_PATH.parent.mkdir(parents=True, exist_ok=True)
+ with SKIPPED_SUPPRESS_PATH.open("w", encoding="utf-8") as f:
+ json.dump(items, f, ensure_ascii=False, indent=2)
+
+
+def list_skip_suppressions() -> list[dict[str, Any]]:
+ with _SKIP_SUPPRESS_LOCK:
+ return _load_skip_suppressions_unlocked()
+
+
+def build_skip_suppression_lookup(items: list[dict[str, Any]]) -> set[str]:
+ keys: set[str] = set()
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ key = str(item.get("key", "")).strip()
+ if key:
+ keys.add(key)
+ continue
+ line = str(item.get("line", "")).strip()
+ reason = str(item.get("reason", "")).strip()
+ fallback = skip_suppression_key(line, reason)
+ if fallback:
+ keys.add(fallback)
+ return keys
+
+
+def is_skip_item_suppressed(line: str, reason: str, lookup: set[str]) -> bool:
+ for key in skip_suppression_keys_for_item(line, reason):
+ if key in lookup:
+ return True
+ return False
+
+
+def suppress_skip_item(line: str, reason: str = "") -> tuple[dict[str, Any], bool]:
+ line_text = str(line or "").strip()
+ if not line_text:
+ raise ValueError("line is required")
+ reason_text = str(reason or "").strip()
+ # "*" means suppress the same line regardless of reason.
+ normalized_reason = reason_text if reason_text else "*"
+ key = skip_suppression_key(line_text, normalized_reason)
+ if not key:
+ raise ValueError("line is required")
+
+ now = datetime.now().isoformat(timespec="seconds")
+ with _SKIP_SUPPRESS_LOCK:
+ items = _load_skip_suppressions_unlocked()
+ for item in items:
+ if str(item.get("key", "")).strip() != key:
+ continue
+ item["updated_at"] = now
+ _save_skip_suppressions_unlocked(items)
+ return dict(item), False
+
+ obj = {
+ "id": uuid.uuid4().hex[:12],
+ "key": key,
+ "line": line_text,
+ "reason": normalized_reason,
+ "created_at": now,
+ "updated_at": now,
+ }
+ items.append(obj)
+ _save_skip_suppressions_unlocked(items)
+ return obj, True
+
+
+def clear_skip_suppressions() -> int:
+ with _SKIP_SUPPRESS_LOCK:
+ items = _load_skip_suppressions_unlocked()
+ count = len(items)
+ _save_skip_suppressions_unlocked([])
+ return count
+
+
+def register_active_job_dir(job_dir: Path) -> None:
+ with _ACTIVE_JOB_LOCK:
+ ACTIVE_JOB_DIRS.add(str(job_dir.resolve()))
+
+
+def unregister_active_job_dir(job_dir: Path) -> None:
+ with _ACTIVE_JOB_LOCK:
+ ACTIVE_JOB_DIRS.discard(str(job_dir.resolve()))
+
+
+def get_active_job_dirs() -> set[str]:
+ with _ACTIVE_JOB_LOCK:
+ return set(ACTIVE_JOB_DIRS)
+
+
+def acquire_generation_slot(token: str = "") -> bool:
+ if not _GENERATE_SLOT_LOCK.acquire(blocking=False):
+ return False
+ with _GENERATE_STATE_LOCK:
+ ACTIVE_GENERATION["token"] = str(token).strip()
+ ACTIVE_GENERATION["started_at"] = time.time()
+ return True
+
+
+def release_generation_slot(token: str = "") -> None:
+ _ = token
+ with _GENERATE_STATE_LOCK:
+ ACTIVE_GENERATION["token"] = ""
+ ACTIVE_GENERATION["started_at"] = 0.0
+ if _GENERATE_SLOT_LOCK.locked():
+ try:
+ _GENERATE_SLOT_LOCK.release()
+ except RuntimeError:
+ pass
+
+
+def get_active_generation() -> dict[str, Any]:
+ with _GENERATE_STATE_LOCK:
+ return dict(ACTIVE_GENERATION)
+
+
+def today_log_path() -> Path:
+ day = datetime.now().strftime("%Y-%m-%d")
+ return REVIEW_LOG_DIR / f"review_{day}.jsonl"
+
+
+def count_log_lines(path: Path) -> int:
+ if not path.exists():
+ return 0
+ n = 0
+ with path.open("r", encoding="utf-8") as f:
+ for _ in f:
+ n += 1
+ return n
+
+
+def append_review_log(event: str, payload: dict[str, Any] | None = None) -> Path:
+ REVIEW_LOG_DIR.mkdir(parents=True, exist_ok=True)
+ log_path = today_log_path()
+ entry = {
+ "ts": datetime.now().isoformat(timespec="seconds"),
+ "event": str(event),
+ }
+ if isinstance(payload, dict):
+ entry.update(payload)
+
+ with _LOG_LOCK:
+ with log_path.open("a", encoding="utf-8") as f:
+ f.write(json.dumps(entry, ensure_ascii=False))
+ f.write("\n")
+ return log_path
+
+
+def get_valid_status_list(config: dict[str, Any]) -> list[str]:
+ valid_status = config.get("replace_algorithm", {}).get("status", {}).get("valid_status", [])
+ if not isinstance(valid_status, list) or not valid_status:
+ valid_status = config.get("status_extraction", {}).get("valid_status", [])
+ if not isinstance(valid_status, list):
+ return []
+ return [str(x).strip() for x in valid_status if str(x).strip()]
+
+
+def normalize_status_value(status: str, config: dict[str, Any]) -> str:
+ fallback = str(config.get("status_extraction", {}).get("fallback", "成功营销")).strip() or "成功营销"
+ valid_status = get_valid_status_list(config)
+ if not valid_status:
+ return str(status).strip() or fallback
+ s = str(status).strip()
+ if s in valid_status:
+ return s
+ return fallback if fallback in valid_status else valid_status[0]
+
+
+def is_date_header_line(normalized_line: str) -> bool:
+ return bool(re.fullmatch(r"\d{1,2}月\d{1,2}[日号]?", normalized_line))
+
+
+def log_parse_skipped(skipped: list[dict[str, Any]], source: str) -> int:
+ written = 0
+ for item in skipped:
+ if not isinstance(item, dict):
+ continue
+ reason = str(item.get("reason", "")).strip()
+ # 说明行不记录,避免日志噪音。
+ if not reason or reason == "skip_line_rule":
+ continue
+ source_line = str(item.get("line", "")).strip()
+ append_review_log(
+ "parse_skip",
+ {
+ "source": source,
+ "reason": reason,
+ "source_line": source_line,
+ },
+ )
+ written += 1
+ return written
+
+
+def is_path_under(path: Path, parent: Path) -> bool:
+ try:
+ path.resolve().relative_to(parent.resolve())
+ return True
+ except Exception:
+ return False
+
+
+def cleanup_output_artifacts(output_dir: Path) -> dict[str, int]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ removed_dirs = 0
+ removed_files = 0
+ errors = 0
+ active_dirs = get_active_job_dirs()
+
+ for entry in output_dir.iterdir():
+ try:
+ if entry.is_dir() and entry.name.startswith("job_"):
+ if str(entry.resolve()) in active_dirs:
+ continue
+ shutil.rmtree(entry)
+ removed_dirs += 1
+ continue
+ if entry.is_file() and entry.suffix.lower() in {".png", ".pdf", ".pptx", ".zip"}:
+ entry.unlink(missing_ok=True)
+ removed_files += 1
+ except Exception:
+ errors += 1
+
+ with _DOWNLOAD_LOCK:
+ stale_tokens: list[str] = []
+ for token, meta in DOWNLOAD_CACHE.items():
+ file_path = Path(str(meta.get("file_path", "")))
+ if not file_path.exists() or is_path_under(file_path, output_dir):
+ stale_tokens.append(token)
+ for token in stale_tokens:
+ DOWNLOAD_CACHE.pop(token, None)
+
+ return {
+ "removed_dirs": removed_dirs,
+ "removed_files": removed_files,
+ "errors": errors,
+ }
+
+
+def daily_cleanup_loop(stop_event: threading.Event) -> None:
+ while not stop_event.is_set():
+ now = datetime.now()
+ next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
+ wait_seconds = max((next_midnight - now).total_seconds(), 1.0)
+ if stop_event.wait(wait_seconds):
+ break
+
+ try:
+ config = load_config()
+ output_dir = resolve_output_dir(config)
+ stat = cleanup_output_artifacts(output_dir)
+ print(
+ "[daily-cleanup] done at midnight, "
+ f"removed_dirs={stat['removed_dirs']} removed_files={stat['removed_files']} errors={stat['errors']}"
+ )
+ append_review_log(
+ "daily_cleanup",
+ {
+ "output_dir": str(output_dir),
+ **stat,
+ },
+ )
+ except Exception as exc:
+ print(f"[daily-cleanup] failed: {exc}")
+ append_review_log("daily_cleanup_error", {"error": str(exc)})
+
+
+def build_history_lookup(history: list[dict[str, Any]], key_fields: list[str]) -> dict[str, dict[str, Any]]:
+ lookup: dict[str, dict[str, Any]] = {}
+ for item in history:
+ if not isinstance(item, dict):
+ continue
+ k = key_for_record(item, key_fields)
+ if not k:
+ continue
+ existing = lookup.get(k)
+ if existing is None:
+ lookup[k] = item
+ continue
+ prev_ts = str(existing.get("created_at", ""))
+ curr_ts = str(item.get("created_at", ""))
+ if curr_ts >= prev_ts:
+ lookup[k] = item
+ return lookup
+
+
+def build_history_signature_lookup(history: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ lookup: dict[str, dict[str, Any]] = {}
+ for item in history:
+ if not isinstance(item, dict):
+ continue
+ k = signature_key_for_record(item)
+ if not k:
+ continue
+ existing = lookup.get(k)
+ if existing is None:
+ lookup[k] = item
+ continue
+ prev_ts = str(existing.get("created_at", ""))
+ curr_ts = str(item.get("created_at", ""))
+ if curr_ts >= prev_ts:
+ lookup[k] = item
+ return lookup
+
+
+def resolve_history_image_path(history_record: dict[str, Any], config: dict[str, Any]) -> Path | None:
+ candidates: list[Path] = []
+
+ image_path = str(history_record.get("image_path", "")).strip()
+ if image_path:
+ p = Path(image_path)
+ if not p.is_absolute():
+ p = BASE_DIR / p
+ candidates.append(p)
+
+ output_dir = resolve_output_dir(config)
+ png_file = str(history_record.get("png_file", "")).strip()
+ output_file = str(history_record.get("output_file", "")).strip()
+ job_id = str(history_record.get("job_id", "")).strip()
+
+ if png_file and job_id:
+ candidates.append(output_dir / job_id / png_file)
+ if output_file and job_id:
+ candidates.append(output_dir / job_id / output_file)
+
+ if output_file:
+ # 兼容老历史记录:仅存 output_file 时尝试在 job_* 中定位。
+ for p in output_dir.glob(f"job_*/{output_file}"):
+ candidates.append(p)
+ break
+
+ seen: set[str] = set()
+ for c in candidates:
+ key = str(c)
+ if key in seen:
+ continue
+ seen.add(key)
+ if c.exists() and c.is_file():
+ return c
+ return None
+
+
+def attach_duplicate_download_info(
+ dup_record: dict[str, Any],
+ history_record: dict[str, Any] | None,
+ config: dict[str, Any],
+) -> None:
+ if not isinstance(history_record, dict):
+ return
+
+ image_path = resolve_history_image_path(history_record, config)
+ if not image_path:
+ return
+
+ token = register_download(image_path, content_type="image/png")
+ dup_record["download_token"] = token
+ dup_record["download_url"] = f"/api/download/{token}"
+ dup_record["image_name"] = image_path.name
+ if not dup_record.get("output_file"):
+ dup_record["output_file"] = image_path.name
+
+
+def build_history_view_items(
+ history: list[dict[str, Any]],
+ config: dict[str, Any],
+ limit: int = 300,
+) -> list[dict[str, Any]]:
+ items = [x for x in history if isinstance(x, dict)]
+ items.sort(key=lambda x: str(x.get("created_at", "")), reverse=True)
+ if limit > 0:
+ items = items[:limit]
+
+ rows: list[dict[str, Any]] = []
+ for item in items:
+ row = dict(item)
+ image_path = resolve_history_image_path(row, config)
+ if image_path:
+ token = register_download(image_path, content_type="image/png")
+ row["download_token"] = token
+ row["download_url"] = f"/api/download/{token}"
+ row["image_exists"] = True
+ row["image_name"] = image_path.name
+ else:
+ row["image_exists"] = False
+ rows.append(row)
+ return rows
+
+
+def _cleanup_progress_cache_locked() -> None:
+ now = time.time()
+ stale: list[str] = []
+ for token, item in GEN_PROGRESS.items():
+ updated_at = float(item.get("updated_at", 0))
+ if now - updated_at > 3 * 3600:
+ stale.append(token)
+ for token in stale:
+ GEN_PROGRESS.pop(token, None)
+
+
+def cleanup_progress_cache() -> None:
+ with _PROGRESS_LOCK:
+ _cleanup_progress_cache_locked()
+
+
+def set_generation_progress(
+ token: str,
+ *,
+ status: str,
+ stage: str,
+ percent: int,
+ detail: str = "",
+ error: str = "",
+) -> None:
+ t = str(token).strip()
+ if not t:
+ return
+ now_iso = datetime.now().isoformat(timespec="seconds")
+ item = {
+ "token": t,
+ "status": status,
+ "stage": stage,
+ "percent": max(0, min(100, int(percent))),
+ "detail": detail,
+ "error": error,
+ "updated_at": time.time(),
+ "updated_at_text": now_iso,
+ }
+ with _PROGRESS_LOCK:
+ _cleanup_progress_cache_locked()
+ GEN_PROGRESS[t] = item
+
+
+def get_generation_progress(token: str) -> dict[str, Any] | None:
+ t = str(token).strip()
+ if not t:
+ return None
+ with _PROGRESS_LOCK:
+ _cleanup_progress_cache_locked()
+ item = GEN_PROGRESS.get(t)
+ if item is None:
+ return None
+ return dict(item)
+
+
+def strip_trailing_zero(num_text: str) -> str:
+ if "." not in num_text:
+ return num_text
+ return num_text.rstrip("0").rstrip(".")
+
+
+def floor_amount_number(value: Any) -> int | None:
+ s = str(value).strip()
+ if not s:
+ return None
+ m = re.search(r"\d+(?:\.\d+)?", s)
+ if not m:
+ return None
+ try:
+ num = float(m.group(0))
+ except ValueError:
+ return None
+ return max(0, int(num))
+
+
+def normalize_amount_text(value: Any) -> str:
+ v = floor_amount_number(value)
+ if v is None:
+ return ""
+ return f"{v}万"
+
+
+def normalize_line(line: str, line_pattern: str) -> str:
+ line = line.strip()
+ if not line:
+ return ""
+
+ try:
+ line = re.sub(line_pattern, "", line)
+ except re.error:
+ pass
+
+ line = re.sub(r"\s+", "", line)
+ return line.strip()
+
+
+def extract_line_serial(source_line: str, line_pattern: str) -> str:
+ line = str(source_line).strip()
+ if not line:
+ return ""
+
+ # Prefer extracting from configured line prefix regex.
+ try:
+ m = re.match(line_pattern, line)
+ if m:
+ prefix = m.group(0)
+ dm = re.search(r"\d+", prefix)
+ if dm:
+ return str(int(dm.group(0)))
+ except re.error:
+ pass
+
+ # Fallback: common numbering formats (e.g. "12、", "12.", "12)").
+ dm = re.match(r"^\s*(\d+)\s*(?:[、,,..\))]\s*)?", line)
+ if dm:
+ return str(int(dm.group(1)))
+ return ""
+
+
+def line_signature(normalized_line: str) -> str:
+ text = re.sub(r"\s+", "", str(normalized_line).strip())
+ if not text:
+ return ""
+ digest = hashlib.sha1(text.encode("utf-8")).hexdigest()
+ return digest[:16]
+
+
+def extract_branch(line: str, config: dict[str, Any]) -> tuple[str | None, str | None]:
+ branches_cfg = config.get("branches", {})
+ allowed = branches_cfg.get("allowed", [])
+ alias = branches_cfg.get("alias", {})
+
+ candidates: list[str] = []
+ if isinstance(alias, dict):
+ candidates.extend(str(k) for k in alias.keys())
+ if isinstance(allowed, list):
+ candidates.extend(str(b) for b in allowed)
+
+ candidates = sorted(set(candidates), key=len, reverse=True)
+
+ raw_branch = None
+ for name in candidates:
+ if name and name in line:
+ raw_branch = name
+ break
+
+ if raw_branch is None:
+ return None, None
+
+ normalized = str(alias.get(raw_branch, raw_branch)) if isinstance(alias, dict) else raw_branch
+ return raw_branch, normalized
+
+
+def match_status_heuristic(line: str, status: str) -> bool:
+ patterns = STATUS_HEURISTIC_PATTERNS.get(str(status).strip(), [])
+ for pattern in patterns:
+ if pattern.search(line):
+ return True
+ return False
+
+
+def extract_status(line: str, config: dict[str, Any]) -> str:
+ # Normalize common wording variants before status matching.
+ status_line = str(line or "").replace("外行", "他行")
+ cfg = config.get("status_extraction", {})
+ rules = cfg.get("extraction_rules", {})
+ order = cfg.get("priority_order", [])
+ fallback = str(cfg.get("fallback", "成功营销"))
+
+ if not isinstance(rules, dict):
+ return fallback
+
+ if not isinstance(order, list) or not order:
+ order = [str(k) for k in rules.keys()]
+
+ for status in order:
+ keywords = rules.get(status, [status])
+ if not isinstance(keywords, list):
+ continue
+ for kw in keywords:
+ kw_text = str(kw)
+ if kw_text and kw_text in status_line:
+ return normalize_status_value(str(status), config)
+
+ valid_status = set(get_valid_status_list(config))
+ for status in order:
+ s = str(status).strip()
+ if not s:
+ continue
+ if valid_status and s not in valid_status:
+ continue
+ if match_status_heuristic(status_line, s):
+ return normalize_status_value(s, config)
+
+ # Heuristic: plain "提现" defaults to 微信提现 unless it clearly indicates 商户.
+ if "提现" in status_line:
+ if "商户" in status_line and "商户提现" in valid_status:
+ return "商户提现"
+ if "微信提现" in valid_status:
+ return "微信提现"
+
+ # Heuristic: "揽收他行40万存一年" 这类语句不能回退到“成功营销”。
+ if "他行" in status_line:
+ if "揽收他行" in valid_status:
+ return "揽收他行"
+ if "他行挖转" in valid_status:
+ return "他行挖转"
+
+ return normalize_status_value(fallback, config)
+
+
+def normalize_chinese_number_text(text: str) -> str:
+ return (
+ str(text)
+ .strip()
+ .replace("點", "点")
+ .replace("拾", "十")
+ .replace("佰", "百")
+ .replace("仟", "千")
+ )
+
+
+def chinese_integer_to_float(text: str) -> float | None:
+ s = normalize_chinese_number_text(text)
+ if not s:
+ return None
+
+ total = 0
+ section = 0
+ number = 0
+ seen = False
+
+ for ch in s:
+ if ch in CHINESE_DIGIT_MAP:
+ number = CHINESE_DIGIT_MAP[ch]
+ seen = True
+ continue
+
+ unit = CHINESE_UNIT_MAP.get(ch)
+ if unit is None:
+ return None
+
+ seen = True
+ if unit in {10, 100, 1000}:
+ if number == 0:
+ number = 1
+ section += number * unit
+ number = 0
+ continue
+
+ section += number
+ if section == 0:
+ section = 1
+ total += section * unit
+ section = 0
+ number = 0
+
+ if not seen:
+ return None
+ return float(total + section + number)
+
+
+def chinese_text_to_float(text: str) -> float | None:
+ s = normalize_chinese_number_text(text)
+ if not s:
+ return None
+
+ if "点" in s:
+ int_part, frac_part = s.split("点", 1)
+ if not frac_part:
+ return None
+ int_value = chinese_integer_to_float(int_part) if int_part else 0.0
+ if int_value is None:
+ return None
+ frac_digits: list[str] = []
+ for ch in frac_part:
+ digit = CHINESE_DIGIT_MAP.get(ch)
+ if digit is None:
+ return None
+ frac_digits.append(str(digit))
+ return float(f"{int(int_value)}.{''.join(frac_digits)}")
+
+ return chinese_integer_to_float(s)
+
+
+def chinese_digit_fraction(text: str) -> float | None:
+ s = normalize_chinese_number_text(text)
+ if not s:
+ return None
+ if not all(ch in CHINESE_DIGIT_MAP for ch in s):
+ return None
+ digits = "".join(str(CHINESE_DIGIT_MAP[ch]) for ch in s)
+ return int(digits) / (10 ** len(digits))
+
+
+def parse_wan_suffix_shorthand(text: str) -> float | None:
+ s = normalize_chinese_number_text(text)
+ if not s:
+ return 0.0
+
+ if any(ch in s for ch in ("十", "百", "千", "万", "亿")):
+ return None
+
+ if "点" in s:
+ right_val = chinese_text_to_float(s)
+ if right_val is None:
+ return None
+ # e.g. "五点五" after "万" => 0.55 万
+ return right_val / 10.0
+
+ return chinese_digit_fraction(s)
+
+
+def parse_chinese_amount_value(raw_text: str, right_1: str, right_2: str) -> float | None:
+ token = normalize_chinese_number_text(raw_text)
+ if not token:
+ return None
+
+ has_digit = any(ch in CHINESE_DIGIT_MAP for ch in token)
+ has_small_unit = any(ch in token for ch in ("十", "百", "千"))
+ if not has_digit and not has_small_unit:
+ return None
+
+ has_wan = ("万" in token) or (right_1 == "万") or right_2.startswith("万元")
+ if has_wan:
+ if "万" in token:
+ left, right = token.split("万", 1)
+ left_text = left.strip()
+ right_text = right.strip()
+
+ left_value = chinese_text_to_float(left_text) if left_text else 0.0
+ if left_value is None:
+ return None
+
+ if not right_text:
+ return left_value
+
+ right_norm = normalize_chinese_number_text(right_text)
+
+ # Full unit expression (e.g. 两万五千、两万零五百) -> convert absolute value to "万".
+ if any(ch in right_norm for ch in ("十", "百", "千", "万", "亿")):
+ absolute = chinese_text_to_float(token)
+ if absolute is None:
+ return None
+ return absolute / 10000.0
+
+ # Colloquial shorthand (e.g. 两万五 -> 2.5万, 一万二 -> 1.2万).
+ shorthand = parse_wan_suffix_shorthand(right_norm)
+ if shorthand is not None:
+ return left_value + shorthand
+
+ right_value = chinese_text_to_float(right_norm)
+ if right_value is None:
+ return left_value
+ return left_value + (right_value / 10.0)
+
+ # e.g. "五 万" where token may be "五"
+ value = chinese_text_to_float(token)
+ return value
+
+ return chinese_text_to_float(token)
+
+
+def extract_amount_candidates(text: str) -> list[dict[str, Any]]:
+ candidates: list[dict[str, Any]] = []
+
+ for m in re.finditer(r"\d+(?:\.\d+)?", text):
+ num_text = m.group(0)
+ start, end = m.span()
+
+ right_2 = text[end : end + 2]
+ right_1 = text[end : end + 1]
+
+ # Exclude time/date/tenor hints: 2月23日, 5年交, 6个月 ...
+ if right_1 in {"年", "月", "期", "号"}:
+ continue
+ if right_2.startswith("个月"):
+ continue
+
+ has_wan = False
+ if right_1 in {"万", "W", "w"} or right_2.startswith("万元"):
+ has_wan = True
+
+ try:
+ value = float(num_text)
+ except ValueError:
+ continue
+
+ candidates.append(
+ {
+ "value": value,
+ "num": strip_trailing_zero(num_text),
+ "start": start,
+ "end": end,
+ "has_wan": has_wan,
+ }
+ )
+
+ for m in CHINESE_NUMBER_RE.finditer(text):
+ raw_text = m.group(0)
+ start, end = m.span()
+
+ right_2 = text[end : end + 2]
+ right_1 = text[end : end + 1]
+
+ if right_1 in {"年", "月", "期", "号"}:
+ continue
+ if right_2.startswith("个月"):
+ continue
+
+ # Avoid treating single Chinese digits in names as amount, e.g. "钱七/周八/吴九".
+ # Keep unit-bearing forms (e.g. "十万", "五万元") untouched.
+ if (
+ len(raw_text) == 1
+ and raw_text in CHINESE_DIGIT_MAP
+ and right_1 not in {"万", "元"}
+ and not right_2.startswith("万元")
+ ):
+ continue
+
+ value = parse_chinese_amount_value(raw_text, right_1, right_2)
+ if value is None:
+ continue
+
+ has_wan = ("万" in raw_text) or (right_1 == "万") or right_2.startswith("万元")
+ candidates.append(
+ {
+ "value": value,
+ "num": strip_trailing_zero(str(value)),
+ "start": start,
+ "end": end,
+ "has_wan": has_wan,
+ }
+ )
+
+ return candidates
+
+
+def amount_num_to_text(num_text: str) -> str:
+ v = floor_amount_number(num_text)
+ if v is None:
+ return ""
+ return f"{v}万"
+
+
+def pick_amount_from_candidates(
+ text: str,
+ candidates: list[dict[str, Any]],
+ anchor_pos: int | None = None,
+) -> str | None:
+ if not candidates:
+ return None
+
+ has_wan_candidates = [c for c in candidates if bool(c.get("has_wan"))]
+
+ if anchor_pos is None:
+ # Prefer "合计/共计" amount, otherwise largest amount.
+ preferred = has_wan_candidates or candidates
+ for c in preferred:
+ near = text[max(0, c["start"] - 2) : c["start"] + 2]
+ if "合计" in near or "共计" in near:
+ return amount_num_to_text(c["num"])
+
+ best = max(preferred, key=lambda x: x["value"])
+ return amount_num_to_text(best["num"])
+
+ def score(c: dict[str, Any]) -> tuple[float, int]:
+ distance = abs(c["start"] - anchor_pos)
+
+ left = text[max(0, c["start"] - 3) : c["start"]]
+ if "合计" in left or "共计" in left:
+ distance -= 3
+
+ # Prefer value on the left side if equally close.
+ side = 0 if c["start"] <= anchor_pos else 1
+ return (distance, side)
+
+ preferred = has_wan_candidates or candidates
+ best = min(preferred, key=score)
+ return amount_num_to_text(best["num"])
+
+
+def split_segments(text: str) -> list[str]:
+ parts = re.split(r"[,,;;。]", text)
+ return [p.strip() for p in parts if p.strip()]
+
+
+def normalize_insurance_year(value: Any) -> str | None:
+ if value is None:
+ return None
+ s = str(value).strip()
+ if s in {"3", "3年", "3年交", "三年", "三年交"}:
+ return "3"
+ if s in {"5", "5年", "5年交", "五年", "五年交"}:
+ return "5"
+ return None
+
+
+def normalize_insurance_year_choices(value: Any) -> dict[str, str]:
+ if not isinstance(value, dict):
+ return {}
+ out: dict[str, str] = {}
+ for k, v in value.items():
+ key = str(k).strip()
+ year = normalize_insurance_year(v)
+ if key and year:
+ out[key] = year
+ return out
+
+
+def build_insurance_choice_key(
+ *,
+ source_line: str,
+ raw_text: str,
+ branch: str,
+ amount: str,
+ page: str,
+ item_index: int,
+) -> str:
+ seed = f"{source_line}|{raw_text}|{branch}|{amount}|{page}|{item_index}"
+ return hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
+
+
+def pick_insurance_year_for_record(
+ *,
+ choice_key: str,
+ source_line: str,
+ raw_text: str,
+ branch: str,
+ amount: str,
+ year_map: dict[str, str],
+ default_year: str | None,
+) -> str | None:
+ candidates = [
+ choice_key,
+ source_line,
+ raw_text,
+ f"{source_line}|{amount}",
+ f"{raw_text}|{amount}",
+ f"{branch}|{amount}",
+ ]
+ for key in candidates:
+ if key and key in year_map:
+ y = normalize_insurance_year(year_map.get(key))
+ if y in {"3", "5"}:
+ return y
+ return normalize_insurance_year(default_year)
+
+
+def make_product(type_keyword: str, page: str, amount: str | None, **kwargs: Any) -> dict[str, Any]:
+ item = {
+ "type": type_keyword,
+ "page": page,
+ "amount": amount,
+ }
+ item.update(kwargs)
+ return item
+
+
+def detect_products(
+ line: str,
+ config: dict[str, Any],
+ insurance_year_choice: str | None,
+) -> list[dict[str, Any]]:
+ insurance_cfg = config.get("insurance_handling", {})
+ insurance_page = str(insurance_cfg.get("page", "page_3")) if isinstance(insurance_cfg, dict) else "page_3"
+
+ products: list[dict[str, Any]] = []
+ seen: set[str] = set()
+
+ segments = split_segments(line)
+ if not segments:
+ segments = [line]
+ line_candidates = extract_amount_candidates(line)
+ seg_search_pos = 0
+
+ def locate_segment_start(seg_text: str) -> int:
+ nonlocal seg_search_pos
+ if not seg_text:
+ return 0
+ pos = line.find(seg_text, seg_search_pos)
+ if pos < 0:
+ pos = line.find(seg_text)
+ if pos >= 0:
+ seg_search_pos = pos + len(seg_text)
+ return pos
+ return 0
+
+ def pick_segment_amount(
+ seg_text: str,
+ seg_candidates: list[dict[str, Any]],
+ seg_anchor: int | None,
+ seg_start: int,
+ ) -> str | None:
+ amount = pick_amount_from_candidates(seg_text, seg_candidates, seg_anchor)
+ if amount:
+ return amount
+ if not line_candidates:
+ return None
+ line_anchor = None
+ if isinstance(seg_anchor, int) and seg_anchor >= 0:
+ line_anchor = seg_start + seg_anchor
+ return pick_amount_from_candidates(line, line_candidates, line_anchor)
+
+ for seg in segments:
+ seg_candidates = extract_amount_candidates(seg)
+ seg_start = locate_segment_start(seg)
+
+ # 1) Insurance (always mapped to 期交 page)
+ if "保险" in seg or "期交" in seg or re.search(r"(?:3|5|三|五)\s*年交", seg):
+ year = None
+ if re.search(r"(?:3|三)\s*年交", seg):
+ year = "3"
+ elif re.search(r"(?:5|五)\s*年交", seg):
+ year = "5"
+ elif insurance_year_choice in {"3", "5"}:
+ year = insurance_year_choice
+
+ insurance_anchor = seg.find("保险")
+ if insurance_anchor < 0:
+ insurance_anchor = 0
+ amount = pick_segment_amount(seg, seg_candidates, insurance_anchor, seg_start)
+
+ if year == "3":
+ type_keyword = "3年交"
+ elif year == "5":
+ type_keyword = "5年交"
+ else:
+ type_keyword = "保险"
+
+ key = f"{type_keyword}|{insurance_page}|{amount}"
+ if key not in seen:
+ products.append(
+ make_product(
+ type_keyword,
+ insurance_page,
+ amount,
+ is_insurance=True,
+ needs_insurance_year=(year is None),
+ )
+ )
+ seen.add(key)
+
+ # 2) Wealth/Fund/Asset
+ if "理财" in seg:
+ amount = pick_segment_amount(seg, seg_candidates, seg.find("理财"), seg_start)
+ key = f"理财|page_5|{amount}"
+ if key not in seen:
+ products.append(make_product("理财", "page_5", amount))
+ seen.add(key)
+
+ if "基金" in seg:
+ amount = pick_segment_amount(seg, seg_candidates, seg.find("基金"), seg_start)
+ key = f"基金|page_7|{amount}"
+ if key not in seen:
+ products.append(make_product("基金", "page_7", amount))
+ seen.add(key)
+
+ if "资管" in seg:
+ amount = pick_segment_amount(seg, seg_candidates, seg.find("资管"), seg_start)
+ key = f"资管|page_6|{amount}"
+ if key not in seen:
+ products.append(make_product("资管", "page_6", amount))
+ seen.add(key)
+
+ # 3) Fixed-term deposit variants
+ for term in TERM_DEFS:
+ rgx = term["regex"]
+ for m in rgx.finditer(seg):
+ # Avoid interpreting "2年理财" as one-year term.
+ if term["page"] == "page_2" and "理财" in seg and "定期" not in seg and "存" not in seg:
+ continue
+
+ amount = pick_segment_amount(seg, seg_candidates, m.start(), seg_start)
+ key = f"{term['type']}|{term['page']}|{amount}"
+ if key not in seen:
+ products.append(make_product(str(term["type"]), str(term["page"]), amount))
+ seen.add(key)
+
+ return products
+
+
+def is_demand_deposit_only_line(line: str) -> bool:
+ # "活期"类关键词但没有明确期限(3/6个月、半年、1年、一年)时,默认不生成。
+ demand_markers = [
+ "微信提现",
+ "微信支付宝提现",
+ "支付宝提现",
+ "挖转",
+ "他行",
+ "揽收现金",
+ "存量提升",
+ "揽收商户",
+ "商户提现",
+ "揽收彩礼",
+ ]
+ explicit_product_markers = [
+ "保险",
+ "年交",
+ "理财",
+ "基金",
+ "资管",
+ "趸交",
+ "三个月",
+ "3个月",
+ "六个月",
+ "6个月",
+ "半年",
+ "一年",
+ "1年",
+ ]
+
+ if not extract_amount_candidates(line):
+ return False
+ if not any(x in line for x in demand_markers):
+ return False
+ if any(x in line for x in explicit_product_markers):
+ return False
+ return True
+
+
+def key_for_record(record: dict[str, Any], key_fields: list[str]) -> str:
+ line_serial = str(record.get("line_serial", "")).strip()
+ if line_serial:
+ line_product_index = str(record.get("line_product_index", "")).strip() or "1"
+ sig = str(record.get("line_signature", "")).strip()
+ if not sig:
+ sig = line_signature(str(record.get("raw_text", "")))
+ parts = [f"line:{line_serial}", f"item:{line_product_index}"]
+ if sig:
+ parts.append(f"sig:{sig}")
+ return "|".join(parts)
+
+ return "|".join(str(record.get(k, "")).strip() for k in key_fields)
+
+
+def signature_key_for_record(record: dict[str, Any]) -> str:
+ line_product_index = str(record.get("line_product_index", "")).strip() or "1"
+ sig = str(record.get("line_signature", "")).strip()
+ if not sig:
+ raw_text = str(record.get("raw_text", "")).strip()
+ if raw_text:
+ sig = line_signature(raw_text)
+ else:
+ source_line = str(record.get("source_line", "")).strip()
+ if source_line:
+ normalized_source = normalize_line(source_line, r"^\s*\d+\s*(?:[、,,..\))]\s*)?")
+ sig = line_signature(normalized_source)
+ if not sig:
+ return ""
+ return f"sig:{sig}|item:{line_product_index}"
+
+
+def render_output_filename(config: dict[str, Any], record: dict[str, Any], index: int) -> str:
+ settings = config.get("output_settings", {})
+ pattern = "喜报_{branch}_{index}.png"
+ if isinstance(settings, dict):
+ pattern = str(settings.get("output_pattern", pattern))
+ try:
+ name = pattern.format(branch=record.get("branch", "未知网点"), index=index)
+ except Exception:
+ name = f"喜报_{record.get('branch', '未知网点')}_{index}.png"
+
+ if not name.lower().endswith(".png"):
+ name = f"{name}.png"
+ return name
+
+
+def normalize_branch_value(branch: Any, config: dict[str, Any]) -> str:
+ b = str(branch).strip()
+ alias = config.get("branches", {}).get("alias", {})
+ if isinstance(alias, dict) and b in alias:
+ return str(alias.get(b, b)).strip()
+ return b
+
+
+def infer_page_from_type(type_keyword: str, config: dict[str, Any]) -> str:
+ t = str(type_keyword).strip()
+ if not t:
+ return ""
+
+ type_map = config.get("type_matching", {})
+ if isinstance(type_map, dict):
+ direct = str(type_map.get(t, "")).strip()
+ if direct:
+ return direct
+
+ pairs = [(str(k), str(v)) for k, v in type_map.items()]
+ pairs.sort(key=lambda x: len(x[0]), reverse=True)
+ for k, v in pairs:
+ if k and k in t and v:
+ return v.strip()
+
+ for term in TERM_DEFS:
+ if t == str(term.get("type", "")):
+ return str(term.get("page", "")).strip()
+ return ""
+
+
+def apply_record_overrides(
+ record: dict[str, Any],
+ updates: dict[str, Any],
+ config: dict[str, Any],
+) -> dict[str, Any]:
+ out = dict(record)
+ if not isinstance(updates, dict):
+ return out
+
+ if "branch" in updates:
+ branch = normalize_branch_value(updates.get("branch", ""), config)
+ if branch:
+ out["branch"] = branch
+
+ if "amount" in updates:
+ amount = normalize_amount_text(updates.get("amount", ""))
+ if amount:
+ out["amount"] = amount
+
+ if "type" in updates:
+ t = str(updates.get("type", "")).strip()
+ if t:
+ out["type"] = t
+ if "page" not in updates:
+ page = infer_page_from_type(t, config)
+ if page:
+ out["page"] = page
+
+ if "page" in updates:
+ page = str(updates.get("page", "")).strip()
+ if page:
+ out["page"] = page
+
+ if "status" in updates:
+ status = str(updates.get("status", "")).strip()
+ if status:
+ out["status"] = normalize_status_value(status, config)
+
+ return out
+
+
+def validate_record_for_generation(record: dict[str, Any], config: dict[str, Any]) -> None:
+ branch = str(record.get("branch", "")).strip()
+ amount = normalize_amount_text(record.get("amount", ""))
+ type_keyword = str(record.get("type", "")).strip()
+ page = str(record.get("page", "")).strip()
+ status = str(record.get("status", "")).strip()
+
+ if not branch:
+ raise ValueError("branch is required")
+ allowed = config.get("branches", {}).get("allowed", [])
+ if isinstance(allowed, list) and allowed:
+ allow_set = {str(x).strip() for x in allowed}
+ if branch not in allow_set:
+ raise ValueError(f"branch not allowed: {branch}")
+
+ if not amount:
+ raise ValueError("amount is required")
+ record["amount"] = amount
+
+ if not type_keyword:
+ raise ValueError("type is required")
+ if not page:
+ page = infer_page_from_type(type_keyword, config)
+ if page:
+ record["page"] = page
+ page = str(record.get("page", "")).strip()
+ if not page:
+ raise ValueError("page is required")
+
+ pages = config.get("pages", {})
+ if isinstance(pages, dict) and page not in pages:
+ raise ValueError(f"invalid page: {page}")
+
+ if not status:
+ status = str(config.get("status_extraction", {}).get("fallback", "成功营销")).strip() or "成功营销"
+ valid_status = get_valid_status_list(config)
+ if valid_status and status not in valid_status:
+ raise ValueError(f"invalid status: {status}")
+ record["status"] = normalize_status_value(status, config)
+
+
+def match_manual_rule(rule: dict[str, Any], source_line: str, normalized_line: str) -> bool:
+ if not isinstance(rule, dict) or not bool(rule.get("enabled", True)):
+ return False
+ keyword = str(rule.get("keyword", "")).strip()
+ if not keyword:
+ return False
+ mode = str(rule.get("match_mode", "normalized")).strip().lower()
+ if mode == "source":
+ return keyword in source_line
+ if mode == "both":
+ return keyword in source_line or keyword in normalized_line
+ return keyword in normalized_line
+
+
+def apply_manual_rules_to_record(
+ record: dict[str, Any],
+ *,
+ source_line: str,
+ normalized_line: str,
+ rules: list[dict[str, Any]],
+ config: dict[str, Any],
+) -> tuple[dict[str, Any], list[str]]:
+ if not rules:
+ return record, []
+
+ out = dict(record)
+ hit_ids: list[str] = []
+ for rule in rules:
+ if not match_manual_rule(rule, source_line, normalized_line):
+ continue
+ updates = rule.get("updates", {})
+ if not isinstance(updates, dict):
+ continue
+ try:
+ out = apply_record_overrides(out, updates, config)
+ except Exception:
+ continue
+ hit_ids.append(str(rule.get("id", "")))
+ return out, [x for x in hit_ids if x]
+
+
+def save_or_update_manual_rule(
+ *,
+ keyword: str,
+ updates: dict[str, Any],
+ note: str = "",
+ match_mode: str = "normalized",
+) -> dict[str, Any]:
+ kw = re.sub(r"\s+", "", str(keyword).strip())
+ if not kw:
+ raise ValueError("rule keyword is required")
+ if not isinstance(updates, dict) or not updates:
+ raise ValueError("rule updates is required")
+
+ normalized_updates: dict[str, Any] = {}
+ for k in ("branch", "type", "page", "status", "amount"):
+ if k in updates:
+ v = str(updates.get(k, "")).strip()
+ if v:
+ normalized_updates[k] = v
+ if not normalized_updates:
+ raise ValueError("rule updates is empty")
+
+ mode = str(match_mode or "normalized").strip().lower()
+ if mode not in {"source", "normalized", "both"}:
+ mode = "normalized"
+
+ rules = load_manual_rules()
+ now = datetime.now().isoformat(timespec="seconds")
+ for rule in rules:
+ if not isinstance(rule, dict):
+ continue
+ if str(rule.get("keyword", "")).strip() != kw:
+ continue
+ if str(rule.get("match_mode", "normalized")).strip().lower() != mode:
+ continue
+ if rule.get("updates") != normalized_updates:
+ continue
+ rule["updated_at"] = now
+ if note:
+ rule["note"] = note
+ save_manual_rules(rules)
+ return dict(rule)
+
+ item = {
+ "id": uuid.uuid4().hex[:12],
+ "keyword": kw,
+ "match_mode": mode,
+ "updates": normalized_updates,
+ "enabled": True,
+ "created_at": now,
+ "updated_at": now,
+ }
+ if note:
+ item["note"] = note
+ rules.append(item)
+ save_manual_rules(rules)
+ return dict(item)
+
+
+def infer_correction_rule_keyword(
+ *,
+ source_line: str,
+ normalized_line: str,
+ corrected_record: dict[str, Any],
+) -> str:
+ status = str(corrected_record.get("status", "")).strip()
+ if status and (status in normalized_line or status in source_line):
+ return status
+
+ t = str(corrected_record.get("type", "")).strip()
+ if t and (t in normalized_line or t in source_line) and len(t) <= 8:
+ return t
+
+ if normalized_line:
+ return normalized_line[:24]
+ return re.sub(r"\s+", "", source_line)[:24]
+
+
+def parse_records(
+ raw_text: str,
+ config: dict[str, Any],
+ history: list[dict[str, Any]],
+ insurance_year_choice: str | None = None,
+ insurance_year_choices: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ relay_cfg = config.get("relay_handling", {})
+ parse_rules = relay_cfg.get("parse_rules", {}) if isinstance(relay_cfg, dict) else {}
+ trigger = str(relay_cfg.get("trigger_keyword", "#接龙"))
+ line_pattern = str(parse_rules.get("line_pattern", r"^\d+、\s*"))
+ skip_lines = parse_rules.get("skip_lines", [])
+ skip_lines = [str(x) for x in skip_lines] if isinstance(skip_lines, list) else []
+
+ has_trigger = trigger in raw_text
+ lines = [line.strip() for line in raw_text.splitlines() if line.strip()]
+
+ filtered_lines: list[dict[str, str]] = []
+ skipped: list[dict[str, str]] = []
+ for line in lines:
+ source_line = line.strip()
+ if trigger and trigger in source_line and source_line == trigger:
+ continue
+
+ normalized = normalize_line(source_line, line_pattern)
+ if not normalized:
+ continue
+
+ if is_date_header_line(normalized):
+ skipped.append({"line": source_line, "reason": "skip_line_rule"})
+ continue
+
+ if any(token and token in normalized for token in skip_lines):
+ skipped.append({"line": source_line, "reason": "skip_line_rule"})
+ continue
+
+ filtered_lines.append(
+ {
+ "source_line": source_line,
+ "normalized_line": normalized,
+ "line_serial": extract_line_serial(source_line, line_pattern),
+ }
+ )
+
+ if not filtered_lines and raw_text.strip():
+ source_line = raw_text.strip()
+ normalized_single = normalize_line(source_line, line_pattern)
+ if normalized_single:
+ filtered_lines = [
+ {
+ "source_line": source_line,
+ "normalized_line": normalized_single,
+ "line_serial": extract_line_serial(source_line, line_pattern),
+ }
+ ]
+
+ branches_cfg = config.get("branches", {})
+ allowed_branches = set(str(x) for x in branches_cfg.get("allowed", [])) if isinstance(branches_cfg, dict) else set()
+
+ dedup_cfg = relay_cfg.get("dedup", {}) if isinstance(relay_cfg, dict) else {}
+ key_fields = dedup_cfg.get("key_fields", ["branch", "amount", "type"])
+ if not isinstance(key_fields, list) or not key_fields:
+ key_fields = ["branch", "amount", "type"]
+ key_fields = [str(k) for k in key_fields]
+ insurance_year_map = dict(insurance_year_choices) if isinstance(insurance_year_choices, dict) else {}
+
+ history_lookup = build_history_lookup(history, key_fields)
+ history_keys = set(history_lookup.keys())
+ history_signature_lookup = build_history_signature_lookup(history)
+ history_signature_keys = set(history_signature_lookup.keys())
+ batch_keys: set[str] = set()
+ batch_signature_keys: set[str] = set()
+ suppressed_lookup = build_skip_suppression_lookup(list_skip_suppressions())
+ manual_rules = load_manual_rules()
+
+ records: list[dict[str, Any]] = []
+ new_records: list[dict[str, Any]] = []
+ pending_insurance_records: list[dict[str, Any]] = []
+ duplicate_records: list[dict[str, Any]] = []
+
+ for line_item in filtered_lines:
+ source_line = str(line_item.get("source_line", ""))
+ normalized_line = str(line_item.get("normalized_line", ""))
+ line_serial = str(line_item.get("line_serial", "")).strip()
+ normalized_signature = line_signature(normalized_line)
+
+ branch_raw, branch = extract_branch(normalized_line, config)
+ if not branch:
+ skipped.append({"line": source_line, "reason": "branch_not_found"})
+ continue
+
+ if allowed_branches and branch not in allowed_branches:
+ skipped.append({"line": source_line, "reason": f"branch_not_allowed:{branch}"})
+ continue
+
+ status = normalize_status_value(extract_status(normalized_line, config), config)
+ products = detect_products(normalized_line, config, insurance_year_choice)
+ if not products:
+ if is_demand_deposit_only_line(normalized_line):
+ skipped.append({"line": source_line, "reason": "demand_deposit_not_generate"})
+ else:
+ skipped.append({"line": source_line, "reason": "type_not_found"})
+ continue
+
+ for product_index, product in enumerate(products, start=1):
+ amount = str(product.get("amount", "")).strip()
+ if not amount:
+ skipped.append({"line": source_line, "reason": "amount_not_found"})
+ continue
+
+ type_keyword = str(product.get("type", "")).strip()
+ page = str(product.get("page", "")).strip()
+ if not type_keyword or not page:
+ skipped.append({"line": source_line, "reason": "type_not_found"})
+ continue
+
+ record = {
+ "source_line": source_line,
+ "raw_text": normalized_line,
+ "branch_raw": branch_raw,
+ "branch": branch,
+ "amount": amount,
+ "type": type_keyword,
+ "page": page,
+ "status": status,
+ "needs_insurance_year": bool(product.get("needs_insurance_year", False)),
+ }
+ if line_serial:
+ record["line_serial"] = line_serial
+ if normalized_signature:
+ record["line_signature"] = normalized_signature
+ record["line_product_index"] = str(product_index)
+ if record["needs_insurance_year"]:
+ choice_key = build_insurance_choice_key(
+ source_line=source_line,
+ raw_text=normalized_line,
+ branch=branch,
+ amount=amount,
+ page=page,
+ item_index=product_index,
+ )
+ record["insurance_choice_key"] = choice_key
+ selected_year = pick_insurance_year_for_record(
+ choice_key=choice_key,
+ source_line=source_line,
+ raw_text=normalized_line,
+ branch=branch,
+ amount=amount,
+ year_map=insurance_year_map,
+ default_year=insurance_year_choice,
+ )
+ if selected_year in {"3", "5"}:
+ record["type"] = f"{selected_year}年交"
+ record["needs_insurance_year"] = False
+ record["insurance_year"] = selected_year
+ record, hit_rule_ids = apply_manual_rules_to_record(
+ record,
+ source_line=source_line,
+ normalized_line=normalized_line,
+ rules=manual_rules,
+ config=config,
+ )
+ record["amount"] = normalize_amount_text(record.get("amount", ""))
+ record["status"] = normalize_status_value(str(record.get("status", "")), config)
+ if hit_rule_ids:
+ record["applied_rule_ids"] = hit_rule_ids
+ if record["needs_insurance_year"]:
+ forced_year = normalize_insurance_year(record.get("type"))
+ if forced_year in {"3", "5"}:
+ record["type"] = f"{forced_year}年交"
+ record["needs_insurance_year"] = False
+ record["insurance_year"] = forced_year
+
+ if not str(record.get("amount", "")).strip():
+ skipped.append({"line": source_line, "reason": "amount_not_found"})
+ continue
+ if not str(record.get("page", "")).strip():
+ inferred_page = infer_page_from_type(str(record.get("type", "")), config)
+ if inferred_page:
+ record["page"] = inferred_page
+ pages = config.get("pages", {})
+ if isinstance(pages, dict) and str(record.get("page", "")).strip() not in pages:
+ skipped.append({"line": source_line, "reason": "type_not_found"})
+ continue
+ if allowed_branches and str(record.get("branch", "")).strip() not in allowed_branches:
+ skipped.append({"line": source_line, "reason": f"branch_not_allowed:{record.get('branch', '')}"})
+ continue
+
+ records.append(record)
+
+ dedup_key = key_for_record(record, key_fields)
+ record["dedup_key"] = dedup_key
+ signature_key = signature_key_for_record(record)
+ if signature_key:
+ record["signature_key"] = signature_key
+
+ history_record = history_lookup.get(dedup_key)
+ if history_record is None and signature_key:
+ history_record = history_signature_lookup.get(signature_key)
+
+ if history_record is not None or dedup_key in history_keys or (signature_key and signature_key in history_signature_keys):
+ dup = dict(record)
+ dup["duplicate_reason"] = "history_duplicate"
+ attach_duplicate_download_info(dup, history_record, config)
+ duplicate_records.append(dup)
+ continue
+
+ if dedup_key in batch_keys or (signature_key and signature_key in batch_signature_keys):
+ dup = dict(record)
+ dup["duplicate_reason"] = "input_duplicate"
+ duplicate_records.append(dup)
+ continue
+
+ # Insurance without explicit 3/5 year should ask user only when truly pending.
+ # If this line was manually suppressed in skip rules, keep it out of prompt queue.
+ if record["needs_insurance_year"]:
+ if is_skip_item_suppressed(source_line, "insurance_year_pending", suppressed_lookup):
+ batch_keys.add(dedup_key)
+ if signature_key:
+ batch_signature_keys.add(signature_key)
+ continue
+
+ pending = dict(record)
+ pending["insurance_options"] = ["3", "5"]
+ pending_insurance_records.append(pending)
+ batch_keys.add(dedup_key)
+ if signature_key:
+ batch_signature_keys.add(signature_key)
+ continue
+
+ batch_keys.add(dedup_key)
+ if signature_key:
+ batch_signature_keys.add(signature_key)
+ new_records.append(record)
+
+ for i, rec in enumerate(new_records, start=1):
+ rec["output_file"] = render_output_filename(config, rec, i)
+
+ visible_skipped: list[dict[str, str]] = []
+ for item in skipped:
+ line_text = str(item.get("line", ""))
+ reason_text = str(item.get("reason", ""))
+ if is_skip_item_suppressed(line_text, reason_text, suppressed_lookup):
+ continue
+ visible_skipped.append(item)
+
+ summary = {
+ "input_lines": len(lines),
+ "parsed": len(records),
+ "new": len(new_records),
+ "duplicate": len(duplicate_records),
+ "skipped": len(visible_skipped),
+ "insurance_pending": len(pending_insurance_records),
+ }
+
+ return {
+ "has_trigger": has_trigger,
+ "records": records,
+ "new_records": new_records,
+ "pending_insurance_records": pending_insurance_records,
+ "duplicate_records": duplicate_records,
+ "skipped": visible_skipped,
+ "summary": summary,
+ "dedup_key_fields": key_fields,
+ "needs_insurance_choice": len(pending_insurance_records) > 0,
+ }
+
+
+def append_new_history(
+ history_path: Path,
+ current_history: list[dict[str, Any]],
+ records: list[dict[str, Any]],
+ key_fields: list[str],
+) -> dict[str, Any]:
+ existing = {key_for_record(r, key_fields) for r in current_history}
+ to_add = []
+ now = datetime.now().isoformat(timespec="seconds")
+
+ for rec in records:
+ if not isinstance(rec, dict):
+ continue
+ k = key_for_record(rec, key_fields)
+ if not k or k in existing:
+ continue
+
+ item = dict(rec)
+ item.pop("download_token", None)
+ item.pop("download_url", None)
+ item["created_at"] = now
+ to_add.append(item)
+ existing.add(k)
+
+ merged = current_history + to_add
+ save_history(history_path, merged)
+
+ return {
+ "added": len(to_add),
+ "total": len(merged),
+ }
+
+
+def upsert_history_records(
+ history_path: Path,
+ current_history: list[dict[str, Any]],
+ records: list[dict[str, Any]],
+ key_fields: list[str],
+) -> dict[str, Any]:
+ merged = [x for x in current_history if isinstance(x, dict)]
+ index_map: dict[str, int] = {}
+ for i, item in enumerate(merged):
+ k = key_for_record(item, key_fields)
+ if k:
+ index_map[k] = i
+
+ now = datetime.now().isoformat(timespec="seconds")
+ added = 0
+ replaced = 0
+ for rec in records:
+ if not isinstance(rec, dict):
+ continue
+ item = dict(rec)
+ item.pop("download_token", None)
+ item.pop("download_url", None)
+ item["created_at"] = now
+ k = key_for_record(item, key_fields)
+ if not k:
+ continue
+ if k in index_map:
+ merged[index_map[k]] = item
+ replaced += 1
+ else:
+ index_map[k] = len(merged)
+ merged.append(item)
+ added += 1
+
+ save_history(history_path, merged)
+ return {
+ "added": added,
+ "replaced": replaced,
+ "total": len(merged),
+ }
+
+
+def page_to_slide_index(page_name: str) -> int:
+ m = re.match(r"^page_(\d+)$", page_name)
+ if not m:
+ raise ValueError(f"invalid page name: {page_name}")
+ return int(m.group(1))
+
+
+def safe_filename(name: str) -> str:
+ s = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip()
+ s = re.sub(r"\s+", "_", s)
+ return s or f"file_{uuid.uuid4().hex[:8]}"
+
+
+def amount_to_digits(amount: str) -> str:
+ v = floor_amount_number(amount)
+ if v is None:
+ t = str(amount).strip()
+ return t.replace("万元", "").replace("万", "")
+ return str(v)
+
+
+def pick_paragraph_index(paragraph_count: int, configured_index: int) -> int:
+ if paragraph_count <= 0:
+ return 0
+ if 0 <= configured_index < paragraph_count:
+ return configured_index
+ # Compatible with 1-based config values.
+ one_based = configured_index - 1
+ if 0 <= one_based < paragraph_count:
+ return one_based
+ return 0
+
+
+def paragraph_plain_text(para: Any) -> str:
+ if getattr(para, "runs", None):
+ return "".join((run.text or "") for run in para.runs)
+ return str(getattr(para, "text", "") or "")
+
+
+def set_paragraph_text(para: Any, text: str) -> None:
+ if getattr(para, "runs", None):
+ para.runs[0].text = text
+ for run in para.runs[1:]:
+ run.text = ""
+ return
+ para.text = text
+
+
+def paragraph_has_branch_marker(para: Any) -> bool:
+ txt = paragraph_plain_text(para).strip()
+ return bool(txt) and ("营业所" in txt)
+
+
+def replace_company_placeholder_text(text: str, company_replacement: str) -> str:
+ company_text = str(company_replacement or "").strip()
+ if not company_text:
+ return str(text or "")
+ return re.sub(r"([XxXx**]+)\s*(?=分公司)", company_text, str(text or ""), count=1)
+
+
+def build_branch_text(
+ original_text: str,
+ branch: str,
+ fallback_text: str,
+ company_replacement: str = "",
+) -> str:
+ text = str(original_text or "")
+ if not text.strip():
+ return fallback_text
+
+ # Some templates use placeholder tokens like "XX/**" before "分公司".
+ # Replace them first to avoid rendering "XX分公司".
+ text = replace_company_placeholder_text(text, company_replacement)
+
+ # Keep the template prefix (e.g. "永州市道县分公司"), only replace the branch part.
+ m = re.search(r"(分公司)([^|,。,;;\s]*)营业所", text)
+ if m:
+ return f"{text[:m.start(2)]}{branch}营业所{text[m.end():]}"
+
+ m = re.search(r"([^|,。,;;\s]*)营业所", text)
+ if m:
+ return f"{text[:m.start(1)]}{branch}营业所{text[m.end():]}"
+
+ return fallback_text
+
+
+def replace_branch_in_slide(slide: Any, branch: str, config: dict[str, Any]) -> None:
+ branch_cfg = config.get("replace_algorithm", {}).get("branch", {})
+ location_cfg = config.get("branches", {}).get("template_location", {})
+
+ shape_index = int(branch_cfg.get("shape_index", location_cfg.get("shape_index", 3)))
+ paragraph_index = int(branch_cfg.get("paragraph_index", location_cfg.get("paragraph_index", 1)))
+ fmt = str(branch_cfg.get("format", location_cfg.get("display_format", "{branch}营业所")))
+ company_replacement = str(
+ branch_cfg.get("company_replacement", location_cfg.get("company_replacement", ""))
+ ).strip()
+ new_text = fmt.format(branch=branch)
+
+ target_para = None
+ fallback_para = None
+ target_shape = None
+ cfg_paragraphs = []
+
+ if 0 <= shape_index < len(slide.shapes):
+ cfg_shape = slide.shapes[shape_index]
+ if getattr(cfg_shape, "has_text_frame", False):
+ cfg_paragraphs = cfg_shape.text_frame.paragraphs
+ if cfg_paragraphs:
+ p_index = pick_paragraph_index(len(cfg_paragraphs), paragraph_index)
+ fallback_para = cfg_paragraphs[p_index]
+ if paragraph_has_branch_marker(cfg_paragraphs[p_index]):
+ target_para = cfg_paragraphs[p_index]
+ target_shape = cfg_shape
+ else:
+ for para in cfg_paragraphs:
+ if paragraph_has_branch_marker(para):
+ target_para = para
+ target_shape = cfg_shape
+ break
+
+ if target_para is None:
+ for shape in slide.shapes:
+ if not getattr(shape, "has_text_frame", False):
+ continue
+ paragraphs = shape.text_frame.paragraphs
+ for para in paragraphs:
+ if paragraph_has_branch_marker(para):
+ target_para = para
+ target_shape = shape
+ break
+ if target_para is not None:
+ break
+
+ if target_para is not None:
+ original_text = paragraph_plain_text(target_para)
+ set_paragraph_text(
+ target_para,
+ build_branch_text(original_text, branch, new_text, company_replacement),
+ )
+ if company_replacement and target_shape is not None and getattr(target_shape, "has_text_frame", False):
+ for para in target_shape.text_frame.paragraphs:
+ old_text = paragraph_plain_text(para)
+ new_company_text = replace_company_placeholder_text(old_text, company_replacement)
+ if new_company_text != old_text:
+ set_paragraph_text(para, new_company_text)
+ return
+
+ if fallback_para is not None:
+ set_paragraph_text(fallback_para, new_text)
+
+
+def replace_amount_in_slide(slide: Any, shape_index: int, amount: str) -> None:
+ if shape_index < 0 or shape_index >= len(slide.shapes):
+ return
+
+ shape = slide.shapes[shape_index]
+ if not getattr(shape, "has_text_frame", False):
+ return
+
+ amount_digits = amount_to_digits(amount)
+ candidates: list[tuple[Any, int, str]] = []
+ paragraphs = shape.text_frame.paragraphs
+
+ for para in paragraphs:
+ runs = list(getattr(para, "runs", []) or [])
+ for idx, run in enumerate(runs):
+ t = str(getattr(run, "text", "") or "").strip()
+ if re.fullmatch(r"\d+(?:\.\d+)?", t) or re.fullmatch(r"[\**]+", t):
+ candidates.append((para, idx, t))
+
+ if not candidates:
+ return
+
+ def next_nonempty_text(para: Any, start_idx: int) -> str:
+ runs = list(getattr(para, "runs", []) or [])
+ for j in range(start_idx + 1, len(runs)):
+ txt = str(getattr(runs[j], "text", "") or "").strip()
+ if txt:
+ return txt
+ return ""
+
+ def prev_nonempty_text(para: Any, start_idx: int) -> str:
+ runs = list(getattr(para, "runs", []) or [])
+ for j in range(start_idx - 1, -1, -1):
+ txt = str(getattr(runs[j], "text", "") or "").strip()
+ if txt:
+ return txt
+ return ""
+
+ preferred: list[tuple[Any, int, str]] = []
+ for para, idx, _ in candidates:
+ nxt = next_nonempty_text(para, idx)
+ prv = prev_nonempty_text(para, idx)
+ # Prefer the amount token that is adjacent to "万/万元".
+ if ("万" in nxt) or ("万元" in nxt) or ("万" in prv) or ("万元" in prv):
+ preferred.append((para, idx, ""))
+
+ targets = preferred if preferred else [candidates[-1]]
+ for para, idx, _ in targets:
+ runs = list(getattr(para, "runs", []) or [])
+ if 0 <= idx < len(runs):
+ runs[idx].text = amount_digits
+
+
+def replace_status_in_slide(
+ slide: Any,
+ shape_index: int,
+ status: str,
+ page_name: str,
+ config: dict[str, Any],
+) -> None:
+ if shape_index < 0 or shape_index >= len(slide.shapes):
+ return
+
+ shape = slide.shapes[shape_index]
+ if not getattr(shape, "has_text_frame", False):
+ return
+
+ normalized_status = normalize_status_value(status, config)
+ page_cfg = config.get("pages", {}).get(page_name, {})
+ has_bracket = bool(page_cfg.get("has_bracket", False)) if isinstance(page_cfg, dict) else False
+ target_text = f"({normalized_status})" if has_bracket else normalized_status
+ valid_status = get_valid_status_list(config)
+
+ paragraphs = shape.text_frame.paragraphs
+ if not paragraphs:
+ shape.text_frame.text = target_text
+ return
+
+ target_para = None
+ for para in paragraphs:
+ para_text = "".join(run.text for run in para.runs).strip() if para.runs else para.text.strip()
+ if not para_text:
+ continue
+ if any(s in para_text for s in valid_status) or ("成功营销" in para_text) or ("(" in para_text and ")" in para_text):
+ target_para = para
+ break
+
+ if target_para is None:
+ for para in paragraphs:
+ para_text = "".join(run.text for run in para.runs).strip() if para.runs else para.text.strip()
+ if para_text:
+ target_para = para
+ break
+ if target_para is None:
+ target_para = paragraphs[0]
+
+ if target_para.runs:
+ replaced = False
+
+ # 优先替换已存在的状态 run,保持模板字体/字号完全不变。
+ for run in target_para.runs:
+ raw = run.text or ""
+ trimmed = raw.strip()
+ if not raw:
+ continue
+ if trimmed in valid_status or trimmed == "成功营销":
+ run.text = raw.replace(trimmed, normalized_status, 1)
+ replaced = True
+ break
+
+ if not replaced and has_bracket:
+ for run in target_para.runs:
+ raw = run.text or ""
+ if "(" in raw and ")" in raw:
+ run.text = re.sub(r"([^)]*)", f"({normalized_status})", raw, count=1)
+ replaced = True
+ break
+
+ if not replaced:
+ target_para.runs[0].text = target_text
+ for run in target_para.runs[1:]:
+ run.text = ""
+ else:
+ target_para.text = target_text
+
+ if has_bracket:
+ target_para.alignment = PP_ALIGN.CENTER
+ shape.text_frame.word_wrap = False
+
+
+def replace_page_display_name_in_slide(slide: Any, page_name: str, config: dict[str, Any]) -> None:
+ pages = config.get("pages", {})
+ if not isinstance(pages, dict):
+ return
+ page_cfg = pages.get(page_name, {})
+ if not isinstance(page_cfg, dict):
+ return
+ display_name = str(page_cfg.get("display_name", "")).strip()
+ if not display_name:
+ return
+
+ aliases = page_cfg.get("display_aliases", [])
+ alias_list: list[str] = [display_name]
+ if isinstance(aliases, list):
+ alias_list.extend([str(x).strip() for x in aliases if str(x).strip()])
+ if page_name == "page_5":
+ # 兼容旧模板文案。
+ alias_list.extend(["两三期理财", "两三年期理财"])
+ alias_list = [x for x in dict.fromkeys(alias_list) if x]
+
+ for shape in slide.shapes:
+ if not getattr(shape, "has_text_frame", False):
+ continue
+ paragraphs = shape.text_frame.paragraphs
+ for para in paragraphs:
+ raw_text = paragraph_plain_text(para)
+ compact_text = normalize_skip_line(raw_text)
+ if not compact_text:
+ continue
+ for alias in alias_list:
+ alias_compact = normalize_skip_line(alias)
+ if not alias_compact or alias_compact not in compact_text:
+ continue
+
+ if compact_text == alias_compact:
+ set_paragraph_text(para, display_name)
+ elif alias in raw_text:
+ set_paragraph_text(para, raw_text.replace(alias, display_name, 1))
+ elif len(compact_text) <= 24:
+ set_paragraph_text(para, compact_text.replace(alias_compact, display_name, 1))
+ else:
+ continue
+
+ # 避免“多一个字后自动折行”。
+ shape.text_frame.word_wrap = False
+ break
+
+
+def keep_only_target_slide(prs: Presentation, keep_index: int) -> bool:
+ try:
+ sld_id_list = prs.slides._sldIdLst # type: ignore[attr-defined]
+ total = len(prs.slides)
+ if keep_index < 0 or keep_index >= total:
+ return False
+ for idx in range(total - 1, -1, -1):
+ if idx == keep_index:
+ continue
+ rel_id = sld_id_list[idx].rId
+ prs.part.drop_rel(rel_id)
+ del sld_id_list[idx]
+ return True
+ except Exception:
+ return False
+
+
+def is_single_slide_output_enabled(config: dict[str, Any]) -> bool:
+ perf_cfg = config.get("performance", {})
+ if isinstance(perf_cfg, dict):
+ return bool(perf_cfg.get("single_slide_output", True))
+ return True
+
+
+def resolve_generation_strategy(config: dict[str, Any], total_records: int) -> str:
+ perf_cfg = config.get("performance", {})
+ if not isinstance(perf_cfg, dict):
+ return "legacy"
+
+ strategy = str(perf_cfg.get("generation_strategy", "page_template_cache")).strip().lower()
+ if strategy not in {"legacy", "page_template_cache"}:
+ strategy = "page_template_cache"
+ if strategy == "legacy":
+ return "legacy"
+
+ min_records = perf_cfg.get("template_cache_min_records", 2)
+ try:
+ min_records_int = int(min_records)
+ except Exception:
+ min_records_int = 2
+ if total_records < max(1, min_records_int):
+ return "legacy"
+
+ if not is_single_slide_output_enabled(config):
+ return "legacy"
+
+ return "page_template_cache"
+
+
+def _resolve_perf_section(config: dict[str, Any]) -> dict[str, Any]:
+ perf_cfg = config.get("performance", {})
+ return perf_cfg if isinstance(perf_cfg, dict) else {}
+
+
+def resolve_single_generation_mode(config: dict[str, Any]) -> bool:
+ perf_cfg = _resolve_perf_section(config)
+ return bool(perf_cfg.get("single_generation_mode", True))
+
+
+def resolve_build_workers(config: dict[str, Any], total_records: int) -> int:
+ perf_cfg = _resolve_perf_section(config)
+ raw_workers = perf_cfg.get("max_build_workers", 1)
+ try:
+ workers = int(raw_workers)
+ except Exception:
+ workers = 1
+ workers = max(1, min(6, workers))
+ return max(1, min(workers, total_records, (os.cpu_count() or 2)))
+
+
+def resolve_extract_workers(config: dict[str, Any]) -> int:
+ perf_cfg = _resolve_perf_section(config)
+ raw_workers = perf_cfg.get("max_extract_workers", 1)
+ try:
+ workers = int(raw_workers)
+ except Exception:
+ workers = 1
+ return max(1, min(6, workers, (os.cpu_count() or 2)))
+
+
+def resolve_memory_reclaim_options(config: dict[str, Any]) -> dict[str, bool]:
+ perf_cfg = _resolve_perf_section(config)
+ reclaim_cfg = perf_cfg.get("memory_reclaim", {})
+ if not isinstance(reclaim_cfg, dict):
+ reclaim_cfg = {}
+ enabled = bool(reclaim_cfg.get("enabled", True))
+ return {
+ "enabled": enabled,
+ "gc_collect": enabled and bool(reclaim_cfg.get("gc_collect", True)),
+ "malloc_trim": enabled and bool(reclaim_cfg.get("malloc_trim", True)),
+ }
+
+
+def _try_malloc_trim() -> bool:
+ global _MALLOC_TRIM_FN
+ if os.name != "posix":
+ return False
+ try:
+ if _MALLOC_TRIM_FN is None:
+ libc = ctypes.CDLL("libc.so.6")
+ fn = libc.malloc_trim
+ fn.argtypes = [ctypes.c_size_t]
+ fn.restype = ctypes.c_int
+ _MALLOC_TRIM_FN = fn
+ return bool(_MALLOC_TRIM_FN(0))
+ except Exception:
+ return False
+
+
+def reclaim_runtime_memory(config: dict[str, Any]) -> None:
+ opts = resolve_memory_reclaim_options(config)
+ if not opts["enabled"]:
+ return
+ if opts["gc_collect"]:
+ gc.collect()
+ if opts["malloc_trim"]:
+ _try_malloc_trim()
+
+
+def build_page_template_cache(
+ template_path: Path,
+ page_names: set[str],
+) -> dict[str, bytes]:
+ cache: dict[str, bytes] = {}
+ for page_name in sorted(page_names):
+ prs = Presentation(str(template_path))
+ slide_index = page_to_slide_index(page_name)
+ if slide_index < 0 or slide_index >= len(prs.slides):
+ raise RuntimeError(f"slide index out of range for page={page_name}")
+ if not keep_only_target_slide(prs, slide_index):
+ raise RuntimeError(f"failed to prepare single-slide template for page={page_name}")
+ buffer = io.BytesIO()
+ prs.save(buffer)
+ cache[page_name] = buffer.getvalue()
+ return cache
+
+
+def resolve_image_delivery_options(config: dict[str, Any]) -> dict[str, Any]:
+ perf_cfg = config.get("performance", {})
+ delivery_cfg = perf_cfg.get("image_delivery", {}) if isinstance(perf_cfg, dict) else {}
+ if not isinstance(delivery_cfg, dict):
+ delivery_cfg = {}
+
+ enabled = bool(delivery_cfg.get("enabled", True))
+
+ raw_max_kbps = delivery_cfg.get("max_kbps", 300)
+ try:
+ max_kbps = int(raw_max_kbps)
+ except Exception:
+ max_kbps = 300
+ max_kbps = max(0, max_kbps)
+
+ raw_chunk_kb = delivery_cfg.get("chunk_kb", 16)
+ try:
+ chunk_kb = int(raw_chunk_kb)
+ except Exception:
+ chunk_kb = 16
+ chunk_kb = max(4, min(256, chunk_kb))
+
+ if not enabled:
+ max_kbps = 0
+
+ return {
+ "enabled": enabled,
+ "max_kbps": max_kbps,
+ "chunk_size": chunk_kb * 1024,
+ }
+
+
+def build_ppt_for_record(
+ template_path: Path,
+ config: dict[str, Any],
+ record: dict[str, Any],
+ out_pptx: Path,
+ page_template_cache: dict[str, bytes] | None = None,
+) -> int:
+ page_name = str(record.get("page", ""))
+ cached_payload = page_template_cache.get(page_name) if isinstance(page_template_cache, dict) else None
+ if cached_payload:
+ prs = Presentation(io.BytesIO(cached_payload))
+ slide_index = 0
+ else:
+ prs = Presentation(str(template_path))
+ slide_index = page_to_slide_index(page_name)
+ if slide_index < 0 or slide_index >= len(prs.slides):
+ raise RuntimeError(f"slide index out of range for page={record.get('page')}")
+
+ slide = prs.slides[slide_index]
+ page_cfg = config.get("pages", {}).get(page_name, {})
+ amount_shape = int(page_cfg.get("amount_shape", 2))
+ status_shape = int(page_cfg.get("status_shape", 3))
+ normalized_status = normalize_status_value(str(record.get("status", "")), config)
+
+ replace_branch_in_slide(slide, str(record.get("branch", "")), config)
+ replace_amount_in_slide(slide, amount_shape, str(record.get("amount", "")))
+ replace_status_in_slide(slide, status_shape, normalized_status, page_name, config)
+ replace_page_display_name_in_slide(slide, page_name, config)
+
+ single_slide_output = is_single_slide_output_enabled(config)
+ export_page_number = slide_index + 1
+ if not cached_payload and single_slide_output and keep_only_target_slide(prs, slide_index):
+ export_page_number = 1
+
+ prs.save(str(out_pptx))
+ return export_page_number
+
+
+def run_subprocess(args: list[str], timeout: int = 300) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=False,
+ timeout=timeout,
+ )
+
+
+def use_local_office_converter() -> bool:
+ return bool(LOCAL_LIBREOFFICE_BIN and Path(str(LOCAL_LIBREOFFICE_BIN)).exists())
+
+
+def use_local_pdftoppm() -> bool:
+ return bool(LOCAL_PDFTOPPM_BIN and Path(str(LOCAL_PDFTOPPM_BIN)).exists())
+
+
+def ensure_converter_image() -> None:
+ global CONVERTER_IMAGE_READY
+ if use_local_office_converter():
+ CONVERTER_IMAGE_READY = True
+ return
+ if CONVERTER_IMAGE_READY:
+ return
+
+ with _CONVERTER_LOCK:
+ if CONVERTER_IMAGE_READY:
+ return
+
+ inspect = run_subprocess(["docker", "image", "inspect", CONVERTER_IMAGE], timeout=60)
+ if inspect.returncode != 0:
+ pull = run_subprocess(["docker", "pull", CONVERTER_IMAGE], timeout=900)
+ if pull.returncode != 0:
+ raise RuntimeError(
+ f"failed to pull docker image {CONVERTER_IMAGE}: {pull.stderr.strip() or pull.stdout.strip()}"
+ )
+
+ CONVERTER_IMAGE_READY = True
+
+
+def prewarm_converter_image(background: bool = True) -> None:
+ def _task() -> None:
+ try:
+ ensure_converter_image()
+ print(f"Converter image ready: {CONVERTER_IMAGE}")
+ except Exception as exc:
+ print(f"Converter image prewarm failed: {exc}")
+
+ if background:
+ t = threading.Thread(target=_task, name="converter-prewarm", daemon=True)
+ t.start()
+ else:
+ _task()
+
+
+def convert_pptx_to_pdf_batch(
+ job_dir: Path,
+ pptx_paths: list[Path],
+ progress_cb: Callable[[int, str, str], None] | None = None,
+) -> list[Path]:
+ if not pptx_paths:
+ return []
+
+ pdf_paths = [p.with_suffix(".pdf") for p in pptx_paths]
+ total = len(pdf_paths)
+ start_ts = time.time()
+ last_percent = -1
+ last_done = -1
+
+ if use_local_office_converter():
+ args = [
+ str(LOCAL_LIBREOFFICE_BIN),
+ "--headless",
+ "--convert-to",
+ "pdf",
+ "--outdir",
+ str(job_dir),
+ ]
+ args.extend([str(p) for p in pptx_paths])
+ else:
+ ensure_converter_image()
+ uid_gid = f"{os.getuid()}:{os.getgid()}"
+ workdir = str(job_dir)
+ args = [
+ "docker",
+ "run",
+ "--rm",
+ "--user",
+ uid_gid,
+ "-v",
+ f"{workdir}:/work",
+ CONVERTER_IMAGE,
+ "libreoffice",
+ "--headless",
+ "--convert-to",
+ "pdf",
+ "--outdir",
+ "/work",
+ ]
+ args.extend([f"/work/{p.name}" for p in pptx_paths])
+
+ proc = subprocess.Popen(
+ args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ while True:
+ ret = proc.poll()
+
+ done = 0
+ for p in pdf_paths:
+ if p.exists():
+ done += 1
+
+ # 70~84 之间滚动,避免长时间停在 70% 不动。
+ percent_by_done = int((done / max(total, 1)) * 14)
+ percent_by_time = min(13, int((time.time() - start_ts) / 2.0))
+ percent = min(84, 70 + max(percent_by_done, percent_by_time))
+ if progress_cb and (done != last_done or percent != last_percent):
+ progress_cb(percent, "PPT转PDF", f"已转 {done}/{total}")
+ last_done = done
+ last_percent = percent
+
+ if ret is not None:
+ break
+ time.sleep(0.35)
+
+ stdout, stderr = proc.communicate()
+ if proc.returncode != 0:
+ raise RuntimeError(f"pptx->pdf batch convert failed: {stderr.strip() or stdout.strip()}")
+
+ if progress_cb:
+ progress_cb(84, "PPT转PDF", f"已转 {total}/{total}")
+
+ missing = [str(p.name) for p in pdf_paths if not p.exists()]
+ if missing:
+ raise RuntimeError(f"pptx->pdf batch convert failed, missing outputs: {', '.join(missing[:5])}")
+
+ return pdf_paths
+
+
+def extract_pdf_pages_to_png_batch(
+ tasks: list[dict[str, Any]],
+ config: dict[str, Any] | None = None,
+ progress_cb: Callable[[int, str, str], None] | None = None,
+) -> None:
+ if not tasks:
+ return
+
+ first_pdf = Path(str(tasks[0]["pdf_path"]))
+ job_dir = first_pdf.parent
+
+ local_pdftoppm = use_local_pdftoppm()
+ map_file = job_dir / "_extract_map.nul"
+ with map_file.open("wb") as f:
+ for t in tasks:
+ if local_pdftoppm:
+ pdf_field = str(Path(str(t["pdf_path"])).resolve())
+ out_field = str(Path(str(t["png_path"])).with_suffix("").resolve())
+ else:
+ pdf_field = Path(str(t["pdf_path"])).name
+ out_field = Path(str(t["png_path"])).with_suffix("").name
+ pdf_name = pdf_field.encode("utf-8")
+ page_number = str(int(t["page_number"])).encode("utf-8")
+ out_prefix = out_field.encode("utf-8")
+ f.write(pdf_name + b"\0" + page_number + b"\0" + out_prefix + b"\0")
+
+ parallel_jobs = resolve_extract_workers(config or {})
+ if local_pdftoppm:
+ script = (
+ "set -e; "
+ f"xargs -0 -P {parallel_jobs} -n 3 sh -c "
+ "'pdf=\"$1\"; page=\"$2\"; out=\"$3\"; "
+ f"\"{LOCAL_PDFTOPPM_BIN}\" -f \"$page\" -singlefile -png \"$pdf\" \"$out\"' _ "
+ f"< {str(map_file)}"
+ )
+ args = ["sh", "-lc", script]
+ else:
+ ensure_converter_image()
+ uid_gid = f"{os.getuid()}:{os.getgid()}"
+ script = (
+ "set -e; "
+ f"xargs -0 -P {parallel_jobs} -n 3 sh -c "
+ "'pdf=\"$1\"; page=\"$2\"; out=\"$3\"; "
+ "pdftoppm -f \"$page\" -singlefile -png \"/work/$pdf\" \"/work/$out\"' _ "
+ "< /work/_extract_map.nul"
+ )
+ args = [
+ "docker",
+ "run",
+ "--rm",
+ "--user",
+ uid_gid,
+ "-v",
+ f"{str(job_dir)}:/work",
+ CONVERTER_IMAGE,
+ "sh",
+ "-lc",
+ script,
+ ]
+ proc = subprocess.Popen(
+ args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ total = len(tasks)
+ last_done = -1
+ last_percent = -1
+ while True:
+ ret = proc.poll()
+ done = 0
+ for t in tasks:
+ if Path(str(t["png_path"])).exists():
+ done += 1
+ percent = min(94, 85 + int((done / max(total, 1)) * 9))
+ if progress_cb and (done != last_done or percent != last_percent):
+ progress_cb(percent, "PDF转PNG", f"已导出 {done}/{total}")
+ last_done = done
+ last_percent = percent
+ if ret is not None:
+ break
+ time.sleep(0.35)
+
+ stdout, stderr = proc.communicate()
+ map_file.unlink(missing_ok=True)
+ if proc.returncode != 0:
+ raise RuntimeError(f"pdf->png batch extract failed: {stderr.strip() or stdout.strip()}")
+
+ missing_png = [str(Path(str(t["png_path"])).name) for t in tasks if not Path(str(t["png_path"])).exists()]
+ if missing_png:
+ raise RuntimeError(f"pdf->png batch extract failed, missing outputs: {', '.join(missing_png[:5])}")
+
+
+def cleanup_download_cache() -> None:
+ now = time.time()
+ to_delete: list[str] = []
+
+ for token, meta in DOWNLOAD_CACHE.items():
+ created_at = float(meta.get("created_at", 0))
+ if now - created_at > 3600: # 1 hour
+ to_delete.append(token)
+
+ for token in to_delete:
+ DOWNLOAD_CACHE.pop(token, None)
+
+
+def register_download(file_path: Path, content_type: str = "application/octet-stream") -> str:
+ token = uuid.uuid4().hex
+ with _DOWNLOAD_LOCK:
+ cleanup_download_cache()
+ DOWNLOAD_CACHE[token] = {
+ "file_path": str(file_path),
+ "filename": file_path.name,
+ "content_type": content_type,
+ "created_at": time.time(),
+ }
+ return token
+
+
+def generate_records(
+ records: list[dict[str, Any]],
+ config: dict[str, Any],
+ template_path: Path,
+ output_dir: Path,
+ progress_cb: Callable[[int, str, str], None] | None = None,
+) -> dict[str, Any]:
+ output_cfg = config.get("output_settings", {})
+ auto_cleanup = bool(output_cfg.get("auto_cleanup_pptx", True)) if isinstance(output_cfg, dict) else True
+
+ job_id = f"job_{now_ts()}_{uuid.uuid4().hex[:6]}"
+ job_dir = output_dir / job_id
+ job_dir.mkdir(parents=True, exist_ok=True)
+ register_active_job_dir(job_dir)
+
+ try:
+ generated_items: list[dict[str, Any]] = []
+ download_images: list[dict[str, Any]] = []
+ tasks: list[dict[str, Any]] = []
+ page_template_cache: dict[str, bytes] | None = None
+
+ used_names: set[str] = set()
+ total_records = len(records)
+
+ generation_strategy = resolve_generation_strategy(config, total_records)
+ if generation_strategy == "page_template_cache" and total_records > 0:
+ page_names = {str(r.get("page", "")).strip() for r in records if str(r.get("page", "")).strip()}
+ if page_names:
+ if progress_cb:
+ progress_cb(14, "准备模板", "构建单页模板缓存")
+ page_template_cache = build_page_template_cache(template_path, page_names)
+ if progress_cb:
+ progress_cb(18, "准备模板", f"已缓存 {len(page_template_cache)} 个页面")
+
+ if progress_cb:
+ progress_cb(20, "生成PPT", f"0/{total_records}")
+
+ for i, rec in enumerate(records, start=1):
+ base_name = safe_filename(str(rec.get("output_file") or f"喜报_{rec.get('branch', '未知网点')}_{i}.png"))
+ if not base_name.lower().endswith(".png"):
+ base_name += ".png"
+
+ final_name = base_name
+ seq = 1
+ while final_name in used_names:
+ final_name = f"{Path(base_name).stem}_{seq}.png"
+ seq += 1
+ used_names.add(final_name)
+
+ ppt_name = f"{Path(final_name).stem}.pptx"
+ ppt_path = job_dir / ppt_name
+ pdf_path = job_dir / f"{Path(final_name).stem}.pdf"
+ png_path = job_dir / final_name
+
+ tasks.append(
+ {
+ "record": rec,
+ "ppt_path": ppt_path,
+ "pdf_path": pdf_path,
+ "png_path": png_path,
+ "page_number": 1,
+ "png_name": final_name,
+ }
+ )
+
+ def _build_one(task: dict[str, Any]) -> None:
+ rec = dict(task.get("record", {}))
+ ppt_path = Path(str(task.get("ppt_path")))
+ try:
+ export_page_number = build_ppt_for_record(
+ template_path,
+ config,
+ rec,
+ ppt_path,
+ page_template_cache=page_template_cache,
+ )
+ task["page_number"] = int(export_page_number)
+ except Exception as exc:
+ append_review_log(
+ "generation_error",
+ {
+ "reason": "build_ppt_failed",
+ "source_line": str(rec.get("source_line") or rec.get("raw_text") or ""),
+ "record": rec,
+ "error": str(exc),
+ },
+ )
+ raise
+
+ max_workers = resolve_build_workers(config, total_records)
+ if total_records <= 1 or max_workers <= 1:
+ done = 0
+ for task in tasks:
+ _build_one(task)
+ done += 1
+ if progress_cb:
+ pct = 20 + int((done / max(total_records, 1)) * 45)
+ progress_cb(pct, "生成PPT", f"{done}/{total_records}")
+ else:
+ done = 0
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
+ futs = [ex.submit(_build_one, t) for t in tasks]
+ for fut in as_completed(futs):
+ fut.result()
+ done += 1
+ if progress_cb:
+ pct = 20 + int((done / max(total_records, 1)) * 45)
+ progress_cb(pct, "生成PPT", f"{done}/{total_records}")
+
+ if progress_cb:
+ progress_cb(70, "PPT转PDF", f"已转 0/{total_records}")
+ convert_pptx_to_pdf_batch(
+ job_dir,
+ [Path(str(t["ppt_path"])) for t in tasks],
+ progress_cb=progress_cb,
+ )
+ if progress_cb:
+ progress_cb(85, "PDF转PNG", "已导出 0/{}".format(total_records))
+ extract_pdf_pages_to_png_batch(tasks, config=config, progress_cb=progress_cb)
+
+ for i, t in enumerate(tasks, start=1):
+ rec = dict(t["record"])
+ ppt_path = Path(str(t["ppt_path"]))
+ pdf_path = Path(str(t["pdf_path"]))
+ png_path = Path(str(t["png_path"]))
+
+ if auto_cleanup:
+ ppt_path.unlink(missing_ok=True)
+ pdf_path.unlink(missing_ok=True)
+
+ item = dict(rec)
+ item["png_file"] = str(t["png_name"])
+ item["job_id"] = job_id
+ item["image_path"] = str(png_path)
+ token = register_download(png_path, content_type="image/png")
+ item["download_token"] = token
+ item["download_url"] = f"/api/download/{token}"
+ generated_items.append(item)
+ download_images.append(
+ {
+ "name": str(t["png_name"]),
+ "download_token": token,
+ "download_url": f"/api/download/{token}",
+ }
+ )
+ if progress_cb:
+ pct = 95 + int((i / max(total_records, 1)) * 5)
+ progress_cb(pct, "整理下载", f"{i}/{total_records}")
+
+ return {
+ "job_id": job_id,
+ "job_dir": str(job_dir),
+ "generation_strategy": generation_strategy,
+ "generated": generated_items,
+ "generated_count": len(generated_items),
+ "download_images": download_images,
+ }
+ finally:
+ unregister_active_job_dir(job_dir)
+ reclaim_runtime_memory(config)
+
+
+class XibaoHandler(SimpleHTTPRequestHandler):
+ def __init__(self, *args: Any, **kwargs: Any):
+ super().__init__(*args, directory=str(STATIC_DIR), **kwargs)
+
+ def _send_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def _send_bytes(
+ self,
+ content: bytes,
+ content_type: str,
+ filename: str | None = None,
+ status: HTTPStatus = HTTPStatus.OK,
+ ) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(content)))
+ if filename:
+ encoded = quote(filename)
+ self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{encoded}")
+ self.end_headers()
+ self.wfile.write(content)
+
+ def _send_file(
+ self,
+ file_path: Path,
+ content_type: str,
+ filename: str | None = None,
+ status: HTTPStatus = HTTPStatus.OK,
+ max_kbps: int = 0,
+ chunk_size: int = 16 * 1024,
+ ) -> None:
+ total_size = int(file_path.stat().st_size)
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(total_size))
+ if filename:
+ encoded = quote(filename)
+ self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{encoded}")
+ self.end_headers()
+
+ max_bytes_per_sec = max(0, int(max_kbps)) * 1024
+ block_size = max(4096, int(chunk_size))
+ sent = 0
+ start_ts = time.perf_counter()
+
+ with file_path.open("rb") as f:
+ while True:
+ chunk = f.read(block_size)
+ if not chunk:
+ break
+ self.wfile.write(chunk)
+ sent += len(chunk)
+
+ if max_bytes_per_sec > 0:
+ elapsed = time.perf_counter() - start_ts
+ expected_elapsed = sent / max_bytes_per_sec
+ if expected_elapsed > elapsed:
+ time.sleep(min(expected_elapsed - elapsed, 0.2))
+
+ def _read_json_body(self) -> dict[str, Any]:
+ try:
+ content_len = int(self.headers.get("Content-Length", "0"))
+ except ValueError:
+ content_len = 0
+
+ body = self.rfile.read(content_len) if content_len > 0 else b"{}"
+ if not body:
+ return {}
+
+ try:
+ return json.loads(body.decode("utf-8"))
+ except json.JSONDecodeError:
+ raise ValueError("invalid JSON body")
+
+ def do_GET(self) -> None: # noqa: N802
+ parsed = urlparse(self.path)
+
+ if handle_get_routes(self, parsed, globals()):
+ return
+
+ if parsed.path == "/":
+ self.path = "/index.html"
+
+ super().do_GET()
+
+ def do_POST(self) -> None: # noqa: N802
+ parsed = urlparse(self.path)
+ if handle_post_routes(self, parsed, globals()):
+ return
+
+ self._send_json({"ok": False, "error": "not found"}, HTTPStatus.NOT_FOUND)
+
+
+def run_server(
+ host: str = "0.0.0.0",
+ port: int = 8787,
+ prewarm: bool = True,
+ prewarm_blocking: bool = False,
+) -> None:
+ if prewarm:
+ prewarm_converter_image(background=not prewarm_blocking)
+
+ cleanup_stop = threading.Event()
+ cleanup_thread = threading.Thread(
+ target=daily_cleanup_loop,
+ args=(cleanup_stop,),
+ name="daily-cleanup",
+ daemon=True,
+ )
+ cleanup_thread.start()
+
+ server = ThreadingHTTPServer((host, port), XibaoHandler)
+ print(f"Xibao Web running on http://{host}:{port}")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ cleanup_stop.set()
+ cleanup_thread.join(timeout=1.0)
+ server.server_close()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Xibao Web server")
+ parser.add_argument("--host", default="0.0.0.0", help="bind host, e.g. 0.0.0.0 or 127.0.0.1")
+ parser.add_argument("--port", type=int, default=8787, help="bind port")
+ parser.add_argument(
+ "--skip-prewarm",
+ action="store_true",
+ help="disable docker converter image prewarm on startup",
+ )
+ parser.add_argument(
+ "--prewarm-blocking",
+ action="store_true",
+ help="prewarm converter image before serving (blocks startup until ready)",
+ )
+ args = parser.parse_args()
+ run_server(
+ host=args.host,
+ port=args.port,
+ prewarm=not args.skip_prewarm,
+ prewarm_blocking=args.prewarm_blocking,
+ )
diff --git a/app/services/__init__.py b/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/services/post_ops.py b/app/services/post_ops.py
new file mode 100644
index 0000000..d6ae421
--- /dev/null
+++ b/app/services/post_ops.py
@@ -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}
diff --git a/app/services/workflows.py b/app/services/workflows.py
new file mode 100644
index 0000000..7fd0926
--- /dev/null
+++ b/app/services/workflows.py
@@ -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")
diff --git a/app/static/app.js b/app/static/app.js
new file mode 100644
index 0000000..773f4bb
--- /dev/null
+++ b/app/static/app.js
@@ -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;
+ });
+})();
diff --git a/app/static/index.html b/app/static/index.html
new file mode 100644
index 0000000..31bdc2c
--- /dev/null
+++ b/app/static/index.html
@@ -0,0 +1,245 @@
+
+
+
+
+
+ 喜报处理初始版
+
+
+
+
+
+
+
+
+
+
输入接龙文本
+ 粘贴后可直接解析或生成
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 输入行0
+ 有效解析0
+ 新增0
+ 重复0
+ 跳过0
+
+
+
+
+
+
+
+
+
新增记录
+ 自动识别后可手动修正或标识
+
+
+
+
+
+ | 网点 |
+ 金额 |
+ 类型 |
+ 页面 |
+ 状态 |
+ 输出文件 |
+ 标记 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
检测到保险记录
+
有保险条目未写明年限,请逐条选择期交年限:
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/static/js/README.md b/app/static/js/README.md
new file mode 100644
index 0000000..f17ae63
--- /dev/null
+++ b/app/static/js/README.md
@@ -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.
diff --git a/app/static/js/core/state.js b/app/static/js/core/state.js
new file mode 100644
index 0000000..88f6c5c
--- /dev/null
+++ b/app/static/js/core/state.js
@@ -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);
diff --git a/app/static/js/main.js b/app/static/js/main.js
new file mode 100644
index 0000000..d62c34f
--- /dev/null
+++ b/app/static/js/main.js
@@ -0,0 +1,2121 @@
+const core = window.XIBAO_CORE;
+if (!core) {
+ throw new Error("XIBAO_CORE is not initialized");
+}
+
+const {
+ state,
+ SKIP_REASON_MAP,
+ DUP_REASON_MAP,
+ MARK_TYPE_MAP,
+ DUP_AUTO_OPEN_LIMIT,
+} = core;
+
+window.__XIBAO_MAIN_READY__ = true;
+const RETRYABLE_STATUS_DEFAULTS = new Set([408, 425, 429, 500, 502, 503, 504]);
+const RETRYABLE_STATUS_NO_429 = new Set([408, 425, 500, 502, 503, 504]);
+const APP_BASE_PATH = (() => {
+ const raw = String(window.__XIBAO_BASE_PATH__ || "").trim();
+ if (!raw || raw === "/") {
+ return "";
+ }
+ return raw.endsWith("/") ? raw.slice(0, -1) : raw;
+})();
+
+function withAppBase(url) {
+ const text = String(url || "");
+ if (!text) {
+ return text;
+ }
+ if (!APP_BASE_PATH) {
+ return text;
+ }
+ if (
+ text.startsWith("http://") ||
+ text.startsWith("https://") ||
+ text.startsWith("//") ||
+ text.startsWith("data:") ||
+ text.startsWith("blob:")
+ ) {
+ return text;
+ }
+ if (text.startsWith("/")) {
+ if (text === APP_BASE_PATH || text.startsWith(`${APP_BASE_PATH}/`)) {
+ return text;
+ }
+ return `${APP_BASE_PATH}${text}`;
+ }
+ return text;
+}
+
+function sleepMs(ms) {
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
+}
+
+function nextRetryDelay(baseMs, attempt) {
+ const base = Math.max(120, Number(baseMs) || 450);
+ return Math.min(2800, base * (attempt + 1));
+}
+
+function isLikelyNetworkError(err) {
+ if (!err) {
+ return false;
+ }
+ const name = String(err.name || "");
+ if (name === "AbortError") {
+ return true;
+ }
+ const msg = String(err.message || err).toLowerCase();
+ return (
+ msg.includes("failed to fetch") ||
+ msg.includes("network") ||
+ msg.includes("timeout") ||
+ msg.includes("timed out") ||
+ msg.includes("load failed")
+ );
+}
+
+function asStatusSet(value, fallbackSet) {
+ if (!Array.isArray(value)) {
+ return fallbackSet;
+ }
+ const out = new Set();
+ value.forEach((x) => {
+ const n = Number(x);
+ if (Number.isFinite(n)) {
+ out.add(n);
+ }
+ });
+ return out.size > 0 ? out : fallbackSet;
+}
+
+async function fetchJsonWithRetry(url, init = {}, opts = {}) {
+ const retries = Math.max(0, Math.floor(Number(opts.retries) || 0));
+ const timeoutMs = Math.max(2000, Math.floor(Number(opts.timeoutMs) || 20000));
+ const retryDelayMs = Math.max(120, Math.floor(Number(opts.retryDelayMs) || 450));
+ const retryStatuses = asStatusSet(opts.retryStatuses, RETRYABLE_STATUS_DEFAULTS);
+ const scopedUrl = withAppBase(url);
+ let lastErr = null;
+
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
+ const fetchInit = { ...(init || {}) };
+ if (controller) {
+ fetchInit.signal = controller.signal;
+ }
+ let timer = null;
+ if (controller && timeoutMs > 0) {
+ timer = setTimeout(() => controller.abort(), timeoutMs);
+ }
+
+ try {
+ const res = await fetch(scopedUrl, fetchInit);
+ const text = await res.text();
+ let data = {};
+ if (text) {
+ try {
+ data = JSON.parse(text);
+ } catch (err) {
+ if (attempt < retries) {
+ await sleepMs(nextRetryDelay(retryDelayMs, attempt));
+ continue;
+ }
+ throw new Error(`接口返回非JSON: ${scopedUrl}`);
+ }
+ }
+
+ if (!res.ok && retryStatuses.has(Number(res.status)) && attempt < retries) {
+ await sleepMs(nextRetryDelay(retryDelayMs, attempt));
+ continue;
+ }
+ return { status: Number(res.status), ok: Boolean(res.ok), data };
+ } catch (err) {
+ lastErr = err;
+ if (attempt < retries && isLikelyNetworkError(err)) {
+ await sleepMs(nextRetryDelay(retryDelayMs, attempt));
+ continue;
+ }
+ throw err;
+ } finally {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+ }
+
+ throw lastErr || new Error(`请求失败: ${scopedUrl}`);
+}
+
+async function fetchBlobWithRetry(url, opts = {}) {
+ const retries = Math.max(0, Math.floor(Number(opts.retries) || 0));
+ const timeoutMs = Math.max(2000, Math.floor(Number(opts.timeoutMs) || 15000));
+ const retryDelayMs = Math.max(120, Math.floor(Number(opts.retryDelayMs) || 450));
+ const retryStatuses = asStatusSet(opts.retryStatuses, RETRYABLE_STATUS_DEFAULTS);
+ const scopedUrl = withAppBase(url);
+ const firstCache = String(opts.cache || "force-cache");
+ let lastErr = null;
+
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
+ const fetchInit = {
+ cache: attempt === 0 ? firstCache : "no-store",
+ };
+ if (controller) {
+ fetchInit.signal = controller.signal;
+ }
+ let timer = null;
+ if (controller && timeoutMs > 0) {
+ timer = setTimeout(() => controller.abort(), timeoutMs);
+ }
+
+ try {
+ const res = await fetch(scopedUrl, fetchInit);
+ if (!res.ok) {
+ const status = Number(res.status);
+ if (retryStatuses.has(status) && attempt < retries) {
+ await sleepMs(nextRetryDelay(retryDelayMs, attempt));
+ continue;
+ }
+ throw new Error("获取图片失败");
+ }
+ return res.blob();
+ } catch (err) {
+ lastErr = err;
+ if (attempt < retries && isLikelyNetworkError(err)) {
+ await sleepMs(nextRetryDelay(retryDelayMs, attempt));
+ continue;
+ }
+ throw err;
+ } finally {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+ }
+
+ throw lastErr || new Error("获取图片失败");
+}
+
+function setMsg(text, isError = false) {
+ const el = document.getElementById("msg");
+ el.textContent = text || "";
+ el.style.color = isError ? "#9f2f2f" : "#5b6f69";
+}
+
+function setProgressVisible(visible) {
+ const wrap = document.getElementById("progress-wrap");
+ if (!wrap) {
+ return;
+ }
+ wrap.classList.toggle("hidden", !visible);
+}
+
+function renderProgress(progress) {
+ const stageEl = document.getElementById("progress-stage");
+ const pctEl = document.getElementById("progress-percent");
+ const detailEl = document.getElementById("progress-detail");
+ const fillEl = document.getElementById("progress-fill");
+ if (!stageEl || !pctEl || !detailEl || !fillEl) {
+ return;
+ }
+
+ const stage = progress?.stage || "处理中";
+ const percent = Math.max(0, Math.min(100, Number(progress?.percent ?? 0)));
+ const detail = progress?.detail || "";
+ const status = progress?.status || "";
+ const error = progress?.error || "";
+
+ stageEl.textContent = stage;
+ pctEl.textContent = `${percent}%`;
+ fillEl.style.width = `${percent}%`;
+ if (status === "error" && error) {
+ detailEl.textContent = `${detail ? `${detail} - ` : ""}${error}`;
+ } else {
+ detailEl.textContent = detail;
+ }
+}
+
+function stopProgressPolling() {
+ if (state.progressTimer) {
+ clearInterval(state.progressTimer);
+ state.progressTimer = null;
+ }
+}
+
+async function fetchProgressOnce(token) {
+ if (!token) {
+ return null;
+ }
+ try {
+ const { status, data } = await fetchJsonWithRetry(`/api/progress/${encodeURIComponent(token)}`, {}, {
+ retries: 2,
+ timeoutMs: 6000,
+ retryDelayMs: 400,
+ retryStatuses: [408, 425, 429, 500, 502, 503, 504],
+ });
+ if (status === 404 || !data.ok || !data.progress) {
+ return null;
+ }
+ renderProgress(data.progress);
+ if (data.progress.status === "done" || data.progress.status === "error") {
+ stopProgressPolling();
+ }
+ return data.progress;
+ } catch (err) {
+ return null;
+ }
+}
+
+function startProgressPolling(token) {
+ stopProgressPolling();
+ state.activeProgressToken = token || "";
+ if (!state.activeProgressToken) {
+ return;
+ }
+ state.progressTimer = setInterval(() => {
+ void fetchProgressOnce(state.activeProgressToken);
+ }, 700);
+}
+
+function getRawText() {
+ return document.getElementById("raw-text").value.trim();
+}
+
+function setRawText(value) {
+ const el = document.getElementById("raw-text");
+ if (!el) {
+ return;
+ }
+ el.value = String(value || "");
+}
+
+function getTemplateFile() {
+ return document.getElementById("template-file").value.trim();
+}
+
+function getOutputDir() {
+ return document.getElementById("output-dir").value.trim();
+}
+
+function translateSkipReason(reason) {
+ if (!reason) {
+ return "未知原因";
+ }
+ if (reason.startsWith("branch_not_allowed:")) {
+ const branch = reason.split(":", 2)[1] || "";
+ return `网点不在白名单: ${branch}`;
+ }
+ return SKIP_REASON_MAP[reason] || reason;
+}
+
+function translateDupReason(reason) {
+ return DUP_REASON_MAP[reason] || reason || "未知原因";
+}
+
+function translateMarkType(markType) {
+ return MARK_TYPE_MAP[markType] || markType || "未知类型";
+}
+
+function normalizeSkipLineText(line) {
+ return String(line || "").replace(/\s+/g, "").trim();
+}
+
+function isSameSkippedItem(a, b) {
+ const lineA = normalizeSkipLineText(a?.line || "");
+ const lineB = normalizeSkipLineText(b?.line || "");
+ const reasonA = String(a?.reason || "").trim();
+ const reasonB = String(b?.reason || "").trim();
+ return lineA && lineA === lineB && reasonA === reasonB;
+}
+
+function toInlineUrl(url) {
+ const scoped = withAppBase(url);
+ if (!scoped) {
+ return "";
+ }
+ return scoped.includes("?") ? `${scoped}&inline=1` : `${scoped}?inline=1`;
+}
+
+function showToast(text, isError = false) {
+ let el = document.getElementById("toast-msg");
+ if (!el) {
+ el = document.createElement("div");
+ el.id = "toast-msg";
+ el.className = "toast-msg";
+ document.body.appendChild(el);
+ }
+ el.textContent = text || "";
+ el.classList.toggle("error", Boolean(isError));
+ el.classList.add("show");
+
+ if (state.toastTimer) {
+ clearTimeout(state.toastTimer);
+ }
+ state.toastTimer = setTimeout(() => {
+ el.classList.remove("show");
+ }, 1200);
+}
+window.showToast = showToast;
+
+function imageCacheKey(downloadUrl) {
+ return toInlineUrl(downloadUrl || "");
+}
+
+async function fetchImageBlobByUrl(downloadUrl, opts = {}) {
+ const url = imageCacheKey(downloadUrl);
+ if (!url) {
+ throw new Error("缺少图片地址");
+ }
+ return fetchBlobWithRetry(url, {
+ retries: Number.isFinite(Number(opts.retries)) ? Number(opts.retries) : 2,
+ timeoutMs: Number.isFinite(Number(opts.timeoutMs)) ? Number(opts.timeoutMs) : 15000,
+ retryDelayMs: 420,
+ retryStatuses: [408, 425, 429, 500, 502, 503, 504],
+ cache: opts.cache || "force-cache",
+ });
+}
+
+function warmImageBlobCache(downloadUrl) {
+ if (!downloadUrl || isMobileClient()) {
+ return;
+ }
+ const key = imageCacheKey(downloadUrl);
+ if (!key || state.imageBlobCache.has(key) || state.imageBlobPromises.has(key)) {
+ return;
+ }
+ const task = fetchImageBlobByUrl(downloadUrl)
+ .then((blob) => {
+ state.imageBlobCache.set(key, blob);
+ return blob;
+ })
+ .catch(() => null)
+ .finally(() => {
+ state.imageBlobPromises.delete(key);
+ });
+ state.imageBlobPromises.set(key, task);
+}
+
+function warmGeneratedImages(items) {
+ // Preview now loads images strictly in-order to avoid burst traffic.
+ // Keep generated prewarm disabled to prevent parallel image pulls.
+ void items;
+}
+
+function setupCopyIntentPrefetch(button, downloadUrl) {
+ if (!button || !downloadUrl || isMobileClient()) {
+ return;
+ }
+ const warmOnce = () => {
+ warmImageBlobCache(downloadUrl);
+ };
+ button.addEventListener("mouseenter", warmOnce, { once: true });
+ button.addEventListener("focus", warmOnce, { once: true });
+ button.addEventListener("touchstart", warmOnce, { once: true });
+}
+
+async function getImageBlob(downloadUrl) {
+ const key = imageCacheKey(downloadUrl);
+ if (!key) {
+ throw new Error("缺少图片地址");
+ }
+ if (state.imageBlobCache.has(key)) {
+ return state.imageBlobCache.get(key);
+ }
+ if (state.imageBlobPromises.has(key)) {
+ const blob = await state.imageBlobPromises.get(key);
+ if (blob) {
+ return blob;
+ }
+ }
+ const blob = await fetchImageBlobByUrl(downloadUrl);
+ state.imageBlobCache.set(key, blob);
+ return blob;
+}
+
+async function postJson(url, body, opts = {}) {
+ const { status, data } = await fetchJsonWithRetry(
+ url,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body || {}),
+ },
+ {
+ retries: Number.isFinite(Number(opts.retries)) ? Number(opts.retries) : 0,
+ timeoutMs: Number.isFinite(Number(opts.timeoutMs)) ? Number(opts.timeoutMs) : 30000,
+ retryDelayMs: Number.isFinite(Number(opts.retryDelayMs)) ? Number(opts.retryDelayMs) : 500,
+ retryStatuses: opts.retryStatuses || [408, 425, 500, 502, 503, 504],
+ }
+ );
+ return { status, data };
+}
+
+function normalizeAmountInput(raw) {
+ const s = String(raw || "").trim();
+ if (!s) {
+ return "";
+ }
+ const m = s.match(/\d+(?:\.\d+)?/);
+ if (!m) {
+ return s;
+ }
+ const n = Math.max(0, Math.floor(Number(m[0]) || 0));
+ return `${n}万`;
+}
+
+function toEditableAmount(raw) {
+ const s = String(raw || "").trim();
+ if (!s) {
+ return "";
+ }
+ return s.replace(/万元/g, "").replace(/万/g, "");
+}
+
+function closeCorrectionModal() {
+ const modal = document.getElementById("correction-modal");
+ modal.classList.add("hidden");
+ state.correctionContext = null;
+}
+
+function openCorrectionModal(record) {
+ const modal = document.getElementById("correction-modal");
+ const branchEl = document.getElementById("corr-branch");
+ const amountEl = document.getElementById("corr-amount");
+ const typeEl = document.getElementById("corr-type");
+ const pageEl = document.getElementById("corr-page");
+ const statusEl = document.getElementById("corr-status");
+ const noteEl = document.getElementById("corr-note");
+ const rememberEl = document.getElementById("corr-remember");
+ const keywordEl = document.getElementById("corr-keyword");
+ const keywordWrap = document.getElementById("corr-keyword-wrap");
+
+ state.correctionContext = {
+ record: { ...(record || {}) },
+ };
+ branchEl.value = record?.branch || "";
+ amountEl.value = toEditableAmount(record?.amount || "");
+ typeEl.value = record?.type || "";
+ pageEl.value = record?.page || "";
+ statusEl.value = record?.status || "";
+ noteEl.value = "";
+ rememberEl.checked = false;
+ keywordEl.value = "";
+ keywordWrap.classList.add("hidden");
+ modal.classList.remove("hidden");
+}
+
+function buildCorrectionOverrides() {
+ const ctx = state.correctionContext || {};
+ const base = ctx.record || {};
+ const branch = document.getElementById("corr-branch").value.trim();
+ const amount = normalizeAmountInput(document.getElementById("corr-amount").value);
+ const type = document.getElementById("corr-type").value.trim();
+ const page = document.getElementById("corr-page").value.trim();
+ const status = document.getElementById("corr-status").value.trim();
+
+ const out = {};
+ if (branch && branch !== (base.branch || "")) {
+ out.branch = branch;
+ }
+ if (amount && amount !== (base.amount || "")) {
+ out.amount = amount;
+ }
+ if (type && type !== (base.type || "")) {
+ out.type = type;
+ }
+ if (page && page !== (base.page || "")) {
+ out.page = page;
+ }
+ if (status && status !== (base.status || "")) {
+ out.status = status;
+ }
+ return out;
+}
+
+async function applyCorrection() {
+ const ctx = state.correctionContext || {};
+ const record = ctx.record;
+ if (!record) {
+ setMsg("缺少待修正记录", true);
+ return;
+ }
+
+ const overrides = buildCorrectionOverrides();
+ const rememberRule = Boolean(document.getElementById("corr-remember").checked);
+ const ruleKeyword = document.getElementById("corr-keyword").value.trim();
+ const note = document.getElementById("corr-note").value.trim();
+
+ setLoading(true);
+ setMsg("修正生成中...");
+ try {
+ const { data } = await postJson("/api/correction/apply", {
+ record,
+ overrides,
+ remember_rule: rememberRule,
+ rule_keyword: ruleKeyword || undefined,
+ note,
+ template_file: getTemplateFile() || undefined,
+ output_dir: getOutputDir() || undefined,
+ }, {
+ retries: 2,
+ timeoutMs: 60000,
+ retryDelayMs: 650,
+ retryStatuses: [408, 425, 500, 502, 503, 504],
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "修正失败");
+ }
+
+ const images = Array.isArray(data.download_images) ? data.download_images : [];
+ if (images.length > 0) {
+ warmGeneratedImages(images);
+ state.lastGeneratedImages = [...images, ...(state.lastGeneratedImages || [])];
+ renderPreview(state.lastGeneratedImages);
+ updateDownloadButtonState(false);
+ }
+ closeCorrectionModal();
+ await loadHistoryView();
+ await loadIssueMarks();
+ await loadConfig();
+ const resolvedCount = Number(data?.resolved_issue_count || 0);
+ let msg = data.rule ? "修正成功并已记住规则。" : "修正成功,已生成新图片。";
+ if (resolvedCount > 0) {
+ msg += ` 已自动清除标识 ${resolvedCount} 条。`;
+ }
+ setMsg(msg);
+ } finally {
+ setLoading(false);
+ }
+}
+
+function appendTextCell(tr, text) {
+ const td = document.createElement("td");
+ td.textContent = text ?? "";
+ tr.appendChild(td);
+ return td;
+}
+
+function appendEmptyRow(tbody, colSpan) {
+ const tr = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = colSpan;
+ td.textContent = "暂无数据";
+ td.className = "muted";
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+}
+
+function makeMiniButton(label, className, onClick) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = `mini-btn ${className || ""}`.trim();
+ btn.textContent = label;
+ btn.addEventListener("click", onClick);
+ return btn;
+}
+
+async function markIssue(markType, sourceLine, record = {}, note) {
+ const line = (sourceLine || "").trim();
+ if (!line) {
+ setMsg("缺少原始行,无法标记日志", true);
+ return;
+ }
+
+ const typeText = markType === "generation_error" ? "生成错误" : "识别错误";
+ let finalNote = typeof note === "string" ? note.trim() : "";
+ if (note === undefined || note === null) {
+ const input = window.prompt(`标记${typeText}备注(可选,留空可直接保存)`, "");
+ if (input === null) {
+ setMsg("已取消标记");
+ return;
+ }
+ finalNote = input.trim();
+ }
+
+ try {
+ const { data } = await postJson("/api/log/mark", {
+ mark_type: markType,
+ source_line: line,
+ record,
+ note: finalNote,
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "标记失败");
+ }
+ setMsg(`已标记${typeText}:${line}`);
+ await loadIssueMarks();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+async function suppressSkippedItem(item) {
+ const line = String(item?.line || "").trim();
+ if (!line) {
+ setMsg("跳过项缺少原始行,无法屏蔽", true);
+ return;
+ }
+
+ try {
+ const { data } = await postJson("/api/skipped/suppress", {
+ line,
+ reason: item?.reason || "",
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "屏蔽失败");
+ }
+
+ if (state.lastResult && Array.isArray(state.lastResult.skipped)) {
+ const remain = state.lastResult.skipped.filter((x) => !isSameSkippedItem(x, item));
+ state.lastResult.skipped = remain;
+ if (state.lastResult.summary && typeof state.lastResult.summary === "object") {
+ state.lastResult.summary.skipped = remain.length;
+ renderSummary(state.lastResult.summary);
+ }
+ renderSkipped(remain);
+ document.getElementById("skip-panel").open = remain.length > 0;
+ }
+ setMsg("已屏蔽该跳过项,清空历史后会恢复。");
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+function renderSummary(summary) {
+ document.getElementById("m-input").textContent = summary?.input_lines ?? 0;
+ document.getElementById("m-parsed").textContent = summary?.parsed ?? 0;
+ document.getElementById("m-new").textContent = summary?.new ?? 0;
+ document.getElementById("m-dup").textContent = summary?.duplicate ?? 0;
+ document.getElementById("m-skip").textContent = summary?.skipped ?? 0;
+}
+
+function renderNewRecords(records) {
+ const tbody = document.getElementById("new-body");
+ tbody.innerHTML = "";
+
+ if (!records || records.length === 0) {
+ appendEmptyRow(tbody, 7);
+ return;
+ }
+
+ records.forEach((row) => {
+ const tr = document.createElement("tr");
+ appendTextCell(tr, row.branch);
+ appendTextCell(tr, row.amount);
+ appendTextCell(tr, row.type);
+ appendTextCell(tr, row.page);
+ appendTextCell(tr, row.status);
+ appendTextCell(tr, row.output_file);
+
+ const actionTd = document.createElement("td");
+ actionTd.className = "cell-actions";
+ const actions = document.createElement("div");
+ actions.className = "actions-inline";
+
+ const sourceLine = row.source_line || row.raw_text || "";
+ const baseRecord = {
+ branch: row.branch,
+ amount: row.amount,
+ type: row.type,
+ page: row.page,
+ status: row.status,
+ source_line: sourceLine,
+ raw_text: row.raw_text || "",
+ output_file: row.output_file || "",
+ };
+
+ actions.appendChild(
+ makeMiniButton("修正", "", () => {
+ openCorrectionModal(baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("识别错", "secondary", () => {
+ void markIssue("recognition_error", sourceLine, baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("生成错", "danger", () => {
+ void markIssue("generation_error", sourceLine, baseRecord);
+ })
+ );
+
+ actionTd.appendChild(actions);
+ tr.appendChild(actionTd);
+ tbody.appendChild(tr);
+ });
+}
+
+function triggerDownload(url) {
+ const a = document.createElement("a");
+ a.href = withAppBase(url);
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+}
+
+function normalizeDownloadFilename(name, fallback = "喜报.png") {
+ const raw = String(name || "").trim();
+ const base = raw || fallback;
+ const cleaned = base.replace(/[\\/:*?"<>|]+/g, "_").trim();
+ if (!cleaned) {
+ return fallback;
+ }
+ return cleaned.toLowerCase().endsWith(".png") ? cleaned : `${cleaned}.png`;
+}
+
+function findImageNameByDownloadUrl(downloadUrl) {
+ const target = String(downloadUrl || "").trim();
+ if (!target) {
+ return "";
+ }
+ const images = Array.isArray(state.lastGeneratedImages) ? state.lastGeneratedImages : [];
+ const found = images.find((it) => String(it?.download_url || "").trim() === target);
+ return String(found?.name || "").trim();
+}
+
+function triggerBlobDownload(blob, filename = "喜报.png") {
+ const fileName = normalizeDownloadFilename(filename);
+ const objectUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = objectUrl;
+ a.download = fileName;
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ window.setTimeout(() => {
+ URL.revokeObjectURL(objectUrl);
+ }, 30000);
+}
+
+function isMobileClient() {
+ const ua = navigator.userAgent || "";
+ return /Android|iPhone|iPad|iPod|Mobile|HarmonyOS/i.test(ua);
+}
+
+function getSingleImageActionLabel() {
+ return isMobileClient() ? "下载" : "复制";
+}
+
+async function copyBlobToClipboard(blob) {
+ if (!navigator?.clipboard?.write || typeof window.ClipboardItem === "undefined") {
+ throw new Error("当前浏览器不支持图片复制");
+ }
+ const contentType = blob.type || "image/png";
+ const item = new window.ClipboardItem({ [contentType]: blob });
+ await navigator.clipboard.write([item]);
+}
+
+async function handleSingleImageAction(downloadUrl, preferredName = "") {
+ if (!downloadUrl) {
+ setMsg("缺少图片地址", true);
+ return;
+ }
+ if (isMobileClient()) {
+ try {
+ const blob = await getImageBlob(downloadUrl);
+ const filename = normalizeDownloadFilename(
+ preferredName || findImageNameByDownloadUrl(downloadUrl),
+ `喜报_${Date.now()}.png`
+ );
+ triggerBlobDownload(blob, filename);
+ setMsg("下载已开始");
+ showToast("下载已开始");
+ return;
+ } catch (err) {
+ triggerDownload(downloadUrl);
+ return;
+ }
+ }
+ try {
+ const blob = await getImageBlob(downloadUrl);
+ await copyBlobToClipboard(blob);
+ setMsg("复制成功");
+ showToast("复制成功");
+ } catch (err) {
+ setMsg(`复制失败:${err?.message || "请检查浏览器权限"}`, true);
+ showToast("复制失败", true);
+ }
+}
+
+function renderDuplicateRecords(records) {
+ const tbody = document.getElementById("dup-body");
+ tbody.innerHTML = "";
+
+ if (!records || records.length === 0) {
+ appendEmptyRow(tbody, 6);
+ return;
+ }
+
+ records.forEach((row) => {
+ const tr = document.createElement("tr");
+ appendTextCell(tr, row.branch);
+ appendTextCell(tr, row.amount);
+ appendTextCell(tr, row.type);
+ appendTextCell(tr, row.status);
+ appendTextCell(tr, translateDupReason(row.duplicate_reason));
+
+ const actionTd = document.createElement("td");
+ actionTd.className = "cell-actions";
+ const actions = document.createElement("div");
+ actions.className = "actions-inline";
+
+ if (row.download_url) {
+ actions.appendChild(
+ makeMiniButton("预览", "secondary", () => {
+ window.open(toInlineUrl(row.download_url), "_blank", "noopener,noreferrer");
+ })
+ );
+ const copyBtn = makeMiniButton(getSingleImageActionLabel(), "secondary", () => {
+ void handleSingleImageAction(row.download_url, row.image_name || row.output_file || "");
+ });
+ setupCopyIntentPrefetch(copyBtn, row.download_url);
+ actions.appendChild(copyBtn);
+ } else {
+ const muted = document.createElement("span");
+ muted.className = "muted";
+ muted.textContent = "暂无图片";
+ actions.appendChild(muted);
+ }
+
+ const sourceLine = row.source_line || row.raw_text || "";
+ const baseRecord = {
+ branch: row.branch,
+ amount: row.amount,
+ type: row.type,
+ page: row.page,
+ status: row.status,
+ source_line: sourceLine,
+ raw_text: row.raw_text || "",
+ output_file: row.output_file || "",
+ duplicate_reason: row.duplicate_reason,
+ };
+
+ actions.appendChild(
+ makeMiniButton("修正", "", () => {
+ openCorrectionModal(baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("识别错", "secondary", () => {
+ void markIssue("recognition_error", sourceLine, baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("生成错", "danger", () => {
+ void markIssue("generation_error", sourceLine, baseRecord);
+ })
+ );
+
+ actionTd.appendChild(actions);
+ tr.appendChild(actionTd);
+ tbody.appendChild(tr);
+ });
+}
+
+function renderHistoryRecords(items, totalCount = 0) {
+ const tbody = document.getElementById("history-body");
+ const note = document.getElementById("history-note");
+ if (!tbody || !note) {
+ return;
+ }
+
+ tbody.innerHTML = "";
+ const rows = Array.isArray(items) ? items : [];
+ note.textContent = `总计 ${totalCount} 条,当前显示 ${rows.length} 条`;
+
+ if (rows.length === 0) {
+ appendEmptyRow(tbody, 7);
+ return;
+ }
+
+ rows.forEach((row) => {
+ const tr = document.createElement("tr");
+ appendTextCell(tr, row.created_at || "");
+ appendTextCell(tr, row.branch || "");
+ appendTextCell(tr, row.amount || "");
+ appendTextCell(tr, row.type || "");
+ appendTextCell(tr, row.status || "");
+ appendTextCell(tr, row.source_line || row.raw_text || "");
+
+ const actionTd = document.createElement("td");
+ actionTd.className = "cell-actions";
+ const actions = document.createElement("div");
+ actions.className = "actions-inline";
+
+ if (row.download_url) {
+ actions.appendChild(
+ makeMiniButton("预览", "secondary", () => {
+ window.open(toInlineUrl(row.download_url), "_blank", "noopener,noreferrer");
+ })
+ );
+ const copyBtn = makeMiniButton(getSingleImageActionLabel(), "secondary", () => {
+ void handleSingleImageAction(row.download_url, row.image_name || row.output_file || "");
+ });
+ setupCopyIntentPrefetch(copyBtn, row.download_url);
+ actions.appendChild(copyBtn);
+ } else {
+ const muted = document.createElement("span");
+ muted.className = "muted";
+ muted.textContent = "图片已清理";
+ actions.appendChild(muted);
+ }
+
+ const sourceLine = row.source_line || row.raw_text || "";
+ const baseRecord = {
+ branch: row.branch,
+ amount: row.amount,
+ type: row.type,
+ page: row.page,
+ status: row.status,
+ source_line: sourceLine,
+ raw_text: row.raw_text || "",
+ output_file: row.output_file || "",
+ created_at: row.created_at,
+ };
+ actions.appendChild(
+ makeMiniButton("修正", "", () => {
+ openCorrectionModal(baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("识别错", "secondary", () => {
+ void markIssue("recognition_error", sourceLine, baseRecord);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("生成错", "danger", () => {
+ void markIssue("generation_error", sourceLine, baseRecord);
+ })
+ );
+
+ actionTd.appendChild(actions);
+ tr.appendChild(actionTd);
+ tbody.appendChild(tr);
+ });
+}
+
+async function loadHistoryView(showMsg = false) {
+ const { data } = await fetchJsonWithRetry("/api/history/view?limit=500", {}, {
+ retries: 2,
+ timeoutMs: 10000,
+ retryDelayMs: 420,
+ retryStatuses: [408, 425, 500, 502, 503, 504],
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "加载历史失败");
+ }
+ renderHistoryRecords(data.items || [], data.count || 0);
+ if (showMsg) {
+ setMsg(`历史已刷新:共 ${data.count || 0} 条。`);
+ }
+ return data;
+}
+
+function buildCorrectionRecordFromIssue(issue) {
+ const rec = issue && typeof issue.record === "object" ? issue.record : {};
+ const sourceLine = String(issue?.source_line || rec.source_line || rec.raw_text || "").trim();
+ return {
+ branch: rec.branch || "",
+ amount: rec.amount || "",
+ type: rec.type || "",
+ page: rec.page || "",
+ status: rec.status || "",
+ source_line: sourceLine,
+ raw_text: rec.raw_text || sourceLine,
+ output_file: rec.output_file || "",
+ };
+}
+
+async function editIssue(issue) {
+ if (!issue?.id) {
+ setMsg("缺少标识ID,无法编辑", true);
+ return;
+ }
+ let markType = window.prompt("标识类型(recognition_error 或 generation_error)", issue.mark_type || "");
+ if (markType === null) {
+ return;
+ }
+ markType = String(markType).trim();
+ if (!["recognition_error", "generation_error"].includes(markType)) {
+ setMsg("标识类型不合法", true);
+ return;
+ }
+
+ let sourceLine = window.prompt("原始行", issue.source_line || "");
+ if (sourceLine === null) {
+ return;
+ }
+ sourceLine = String(sourceLine).trim();
+ if (!sourceLine) {
+ setMsg("原始行不能为空", true);
+ return;
+ }
+
+ const noteInput = window.prompt("备注(可空)", issue.note || "");
+ if (noteInput === null) {
+ return;
+ }
+ const note = String(noteInput).trim();
+
+ try {
+ const { data } = await postJson("/api/issues/update", {
+ id: issue.id,
+ mark_type: markType,
+ source_line: sourceLine,
+ note,
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "更新标识失败");
+ }
+ await loadIssueMarks();
+ setMsg("标识已更新。");
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+async function deleteIssue(issue) {
+ if (!issue?.id) {
+ setMsg("缺少标识ID,无法删除", true);
+ return;
+ }
+ const ok = window.confirm("确认删除该标识?删除后不可恢复。");
+ if (!ok) {
+ return;
+ }
+ try {
+ const { data } = await postJson("/api/issues/delete", { id: issue.id });
+ if (!data.ok) {
+ throw new Error(data.error || "删除标识失败");
+ }
+ await loadIssueMarks();
+ setMsg("标识已删除。");
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+function renderIssueMarks(items, totalCount = 0) {
+ const tbody = document.getElementById("issue-body");
+ const note = document.getElementById("issue-note");
+ if (!tbody || !note) {
+ return;
+ }
+
+ tbody.innerHTML = "";
+ const rows = Array.isArray(items) ? items : [];
+ note.textContent = `活跃标识 ${totalCount} 条,当前显示 ${rows.length} 条`;
+
+ if (rows.length === 0) {
+ appendEmptyRow(tbody, 5);
+ return;
+ }
+
+ rows.forEach((row) => {
+ const tr = document.createElement("tr");
+ appendTextCell(tr, row.updated_at || row.created_at || "");
+ appendTextCell(tr, translateMarkType(row.mark_type));
+ appendTextCell(tr, row.source_line || "");
+ appendTextCell(tr, row.note || "");
+
+ const actionTd = document.createElement("td");
+ actionTd.className = "cell-actions";
+ const actions = document.createElement("div");
+ actions.className = "actions-inline";
+
+ actions.appendChild(
+ makeMiniButton("修正", "", () => {
+ openCorrectionModal(buildCorrectionRecordFromIssue(row));
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("编辑", "secondary", () => {
+ void editIssue(row);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("删除", "danger", () => {
+ void deleteIssue(row);
+ })
+ );
+
+ actionTd.appendChild(actions);
+ tr.appendChild(actionTd);
+ tbody.appendChild(tr);
+ });
+}
+
+async function loadIssueMarks(showMsg = false) {
+ const { data } = await fetchJsonWithRetry("/api/issues?status=active&limit=500", {}, {
+ retries: 2,
+ timeoutMs: 10000,
+ retryDelayMs: 420,
+ retryStatuses: [408, 425, 500, 502, 503, 504],
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "加载标识失败");
+ }
+ renderIssueMarks(data.items || [], data.count || 0);
+ if (showMsg) {
+ setMsg(`标识已刷新:活跃 ${data.count || 0} 条。`);
+ }
+ return data;
+}
+
+function renderSkipped(skipped) {
+ const ul = document.getElementById("skip-list");
+ ul.innerHTML = "";
+
+ if (!skipped || skipped.length === 0) {
+ const li = document.createElement("li");
+ li.textContent = "无跳过项";
+ li.className = "muted";
+ ul.appendChild(li);
+ return;
+ }
+
+ skipped.forEach((item) => {
+ const li = document.createElement("li");
+
+ const main = document.createElement("span");
+ const reason = document.createElement("strong");
+ reason.textContent = translateSkipReason(item.reason);
+ main.appendChild(reason);
+ main.appendChild(document.createTextNode(`:${item.line || ""}`));
+
+ const actions = document.createElement("span");
+ actions.className = "skip-actions";
+ actions.appendChild(
+ makeMiniButton("屏蔽", "danger", () => {
+ void suppressSkippedItem(item);
+ })
+ );
+ actions.appendChild(
+ makeMiniButton("标记识别错", "secondary", () => {
+ void markIssue("recognition_error", item.line || "", {
+ reason: item.reason,
+ stage: "skipped",
+ });
+ })
+ );
+
+ li.appendChild(main);
+ li.appendChild(actions);
+ ul.appendChild(li);
+ });
+}
+
+async function loadPreviewImageOneByOne(img, downloadUrl, token, retry = false) {
+ if (!img || !downloadUrl) {
+ return;
+ }
+ const media = img.closest(".preview-media");
+ if (media) {
+ media.classList.add("is-loading");
+ media.classList.remove("is-error");
+ }
+
+ let blob = null;
+ try {
+ if (retry) {
+ const url = toInlineUrl(downloadUrl);
+ const sep = url.includes("?") ? "&" : "?";
+ const retryUrl = `${url}${sep}_r=${Date.now()}`;
+ blob = await fetchBlobWithRetry(retryUrl, {
+ retries: 1,
+ timeoutMs: 15000,
+ retryDelayMs: 380,
+ retryStatuses: [408, 425, 429, 500, 502, 503, 504],
+ cache: "no-store",
+ });
+ const key = imageCacheKey(downloadUrl);
+ if (key) {
+ state.imageBlobCache.set(key, blob);
+ }
+ } else {
+ blob = await getImageBlob(downloadUrl);
+ }
+ } catch (err) {
+ if (!retry && token === state.previewLoadToken) {
+ return loadPreviewImageOneByOne(img, downloadUrl, token, true);
+ }
+ if (media) {
+ media.classList.remove("is-loading");
+ media.classList.add("is-error");
+ }
+ return;
+ }
+
+ if (token !== state.previewLoadToken || !blob) {
+ return;
+ }
+
+ const objectUrl = URL.createObjectURL(blob);
+ await new Promise((resolve) => {
+ let settled = false;
+
+ const cleanup = () => {
+ img.removeEventListener("load", onLoad);
+ img.removeEventListener("error", onError);
+ };
+
+ const finish = () => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanup();
+ resolve();
+ };
+
+ const onLoad = () => {
+ finish();
+ };
+
+ const onError = () => {
+ finish();
+ };
+
+ img.addEventListener("load", onLoad);
+ img.addEventListener("error", onError);
+ img.src = objectUrl;
+ setTimeout(finish, 20000);
+ });
+ URL.revokeObjectURL(objectUrl);
+
+ if (media && token === state.previewLoadToken) {
+ media.classList.remove("is-loading");
+ media.classList.remove("is-error");
+ }
+}
+
+function updatePreviewNote(total, loaded = null) {
+ const note = document.getElementById("preview-note");
+ if (!note) {
+ return;
+ }
+ const totalNum = Math.max(0, Number(total || 0));
+ if (loaded == null) {
+ note.textContent = totalNum > 0 ? `共 ${totalNum} 张` : "生成后显示";
+ return;
+ }
+ const loadedNum = Math.max(0, Math.min(totalNum, Number(loaded || 0)));
+ if (loadedNum >= totalNum) {
+ note.textContent = `共 ${totalNum} 张`;
+ return;
+ }
+ note.textContent = `共 ${totalNum} 张 · 加载 ${loadedNum}/${totalNum}`;
+}
+
+function resolvePreviewLoadConcurrency() {
+ const n = Number(state.previewLoadConcurrency || 0);
+ if (Number.isFinite(n) && n > 0) {
+ return Math.max(1, Math.min(4, Math.floor(n)));
+ }
+ return isMobileClient() ? 1 : 3;
+}
+
+function startPreviewLoad(queue, totalCount) {
+ state.previewLoadToken += 1;
+ const token = state.previewLoadToken;
+ state.previewLoadedCount = 0;
+ if (!Array.isArray(queue) || queue.length === 0) {
+ updatePreviewNote(totalCount, totalCount);
+ return;
+ }
+ const concurrency = Math.min(resolvePreviewLoadConcurrency(), queue.length);
+ let cursor = 0;
+
+ (async () => {
+ const worker = async () => {
+ while (true) {
+ if (token !== state.previewLoadToken) {
+ return;
+ }
+ const idx = cursor;
+ cursor += 1;
+ if (idx >= queue.length) {
+ return;
+ }
+ const item = queue[idx];
+ if (!item?.img || !item?.downloadUrl) {
+ continue;
+ }
+ await loadPreviewImageOneByOne(item.img, item.downloadUrl, token);
+ if (token !== state.previewLoadToken) {
+ return;
+ }
+ state.previewLoadedCount += 1;
+ updatePreviewNote(totalCount, state.previewLoadedCount);
+ }
+ };
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
+ if (token === state.previewLoadToken) {
+ updatePreviewNote(totalCount, totalCount);
+ }
+ })();
+}
+
+function renderPreview(items) {
+ const grid = document.getElementById("preview-grid");
+ grid.innerHTML = "";
+ state.previewLoadToken += 1;
+
+ if (!items || items.length === 0) {
+ updatePreviewNote(0, null);
+ const empty = document.createElement("div");
+ empty.className = "preview-empty muted";
+ empty.textContent = "暂无预览图";
+ grid.appendChild(empty);
+ return;
+ }
+
+ updatePreviewNote(items.length, 0);
+ const loadQueue = [];
+
+ items.forEach((item) => {
+ const card = document.createElement("article");
+ card.className = "preview-item";
+
+ const media = document.createElement("div");
+ media.className = "preview-media";
+ media.classList.add("is-loading");
+
+ const img = document.createElement("img");
+ img.className = "preview-image";
+ img.loading = "eager";
+ img.decoding = "async";
+ img.alt = item.name || "预览图";
+ const downloadUrl = item.download_url || "";
+ const inlineUrl = toInlineUrl(downloadUrl);
+ if (downloadUrl) {
+ loadQueue.push({ img, downloadUrl });
+ }
+ img.addEventListener("click", () => {
+ if (!downloadUrl) {
+ return;
+ }
+ window.open(inlineUrl, "_blank", "noopener,noreferrer");
+ });
+ media.appendChild(img);
+
+ const canAct = Boolean(item.download_url);
+ if (!canAct) {
+ media.classList.remove("is-loading");
+ media.classList.add("is-error");
+ }
+ const actionBtn = document.createElement("button");
+ actionBtn.className = "preview-btn secondary";
+ actionBtn.type = "button";
+ actionBtn.textContent = getSingleImageActionLabel();
+ actionBtn.disabled = !canAct;
+ const onAction = () => {
+ if (!canAct) {
+ return;
+ }
+ void handleSingleImageAction(item.download_url, item.name || "");
+ };
+ setupCopyIntentPrefetch(actionBtn, item.download_url);
+ actionBtn.addEventListener("click", onAction);
+
+ const bar = document.createElement("div");
+ bar.className = "preview-bar";
+
+ const name = document.createElement("span");
+ name.className = "preview-name";
+ name.textContent = item.name || "未命名图片";
+
+ bar.appendChild(name);
+ bar.appendChild(actionBtn);
+ card.appendChild(media);
+ card.appendChild(bar);
+ grid.appendChild(card);
+ });
+
+ startPreviewLoad(loadQueue, items.length);
+}
+
+function renderResult(result) {
+ state.lastResult = result;
+ state.keyFields = result?.dedup_key_fields || ["branch", "amount", "type"];
+
+ renderSummary(result.summary || {});
+ renderNewRecords(result.new_records || []);
+ renderDuplicateRecords(result.duplicate_records || []);
+ renderSkipped(result.skipped || []);
+
+ const dupCount = (result.duplicate_records || []).length;
+ const skipCount = (result.skipped || []).length;
+ const dupPanel = document.getElementById("dup-panel");
+ const skipPanel = document.getElementById("skip-panel");
+ if (dupPanel) {
+ dupPanel.open = dupCount > 0 && dupCount <= DUP_AUTO_OPEN_LIMIT;
+ }
+ if (skipPanel) {
+ skipPanel.open = skipCount > 0;
+ }
+}
+
+function updateDownloadButtonState(loading = false) {
+ const btn = document.getElementById("download-btn");
+ if (!btn) {
+ return;
+ }
+ const hasImages = Array.isArray(state.lastGeneratedImages) && state.lastGeneratedImages.length > 0;
+ btn.disabled = loading || !hasImages;
+}
+
+function setLoading(loading) {
+ document.getElementById("generate-btn").disabled = loading;
+ document.getElementById("parse-btn").disabled = loading;
+ document.getElementById("force-clear-btn").disabled = loading;
+ document.getElementById("history-refresh-btn").disabled = loading;
+ document.getElementById("clear-btn").disabled = loading;
+ const corrSubmit = document.getElementById("corr-submit");
+ const corrCancel = document.getElementById("corr-cancel");
+ if (corrSubmit) {
+ corrSubmit.disabled = loading;
+ }
+ if (corrCancel) {
+ corrCancel.disabled = loading;
+ }
+ updateDownloadButtonState(loading);
+}
+
+async function triggerMultiDownloads(items) {
+ if (!Array.isArray(items)) {
+ return;
+ }
+ for (let i = 0; i < items.length; i += 1) {
+ const it = items[i] || {};
+ if (it.download_url) {
+ let localDownloaded = false;
+ try {
+ const blob = await getImageBlob(it.download_url);
+ const filename = normalizeDownloadFilename(
+ it.name || findImageNameByDownloadUrl(it.download_url),
+ `喜报_${i + 1}.png`
+ );
+ triggerBlobDownload(blob, filename);
+ localDownloaded = true;
+ } catch (err) {
+ localDownloaded = false;
+ }
+ if (!localDownloaded) {
+ triggerDownload(it.download_url);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 140));
+ }
+ }
+}
+
+function askInsuranceYears(pendingRecords = []) {
+ return new Promise((resolve) => {
+ const modal = document.getElementById("insurance-modal");
+ const listEl = document.getElementById("insurance-items");
+ const btnSubmit = document.getElementById("insurance-submit");
+ const btnCancel = document.getElementById("insurance-cancel");
+ if (!modal || !listEl || !btnSubmit || !btnCancel) {
+ resolve(null);
+ return;
+ }
+
+ const records = Array.isArray(pendingRecords) ? pendingRecords : [];
+ const rowMeta = [];
+ listEl.innerHTML = "";
+
+ const errEl = document.createElement("div");
+ errEl.className = "insurance-error";
+ errEl.textContent = "";
+
+ records.forEach((row, idx) => {
+ const key = String(row?.insurance_choice_key || "").trim() || `pending_${idx + 1}`;
+ const item = document.createElement("div");
+ item.className = "insurance-item";
+
+ const line = document.createElement("div");
+ line.className = "insurance-line";
+ line.textContent = row?.source_line || row?.raw_text || `保险条目 ${idx + 1}`;
+ item.appendChild(line);
+
+ const meta = document.createElement("div");
+ meta.className = "insurance-meta";
+ meta.textContent = `网点:${row?.branch || "-"} | 金额:${row?.amount || "-"} | 标识:${key}`;
+ item.appendChild(meta);
+
+ const options = document.createElement("div");
+ options.className = "insurance-options";
+ const group = `insurance_year_${idx}`;
+ ["3", "5"].forEach((year) => {
+ const label = document.createElement("label");
+ const radio = document.createElement("input");
+ radio.type = "radio";
+ radio.name = group;
+ radio.value = year;
+ label.appendChild(radio);
+ label.appendChild(document.createTextNode(`${year}年交`));
+ options.appendChild(label);
+ });
+ item.appendChild(options);
+
+ rowMeta.push({ key, group });
+ listEl.appendChild(item);
+ });
+ listEl.appendChild(errEl);
+
+ const cleanup = () => {
+ modal.classList.add("hidden");
+ btnSubmit.onclick = null;
+ btnCancel.onclick = null;
+ listEl.innerHTML = "";
+ };
+
+ btnSubmit.onclick = () => {
+ const picked = {};
+ for (const item of rowMeta) {
+ const selected = listEl.querySelector(`input[name="${item.group}"]:checked`);
+ if (!selected) {
+ errEl.textContent = "请为每一条保险记录选择年限后再继续。";
+ return;
+ }
+ picked[item.key] = selected.value;
+ }
+ errEl.textContent = "";
+ cleanup();
+ resolve(picked);
+ };
+
+ btnCancel.onclick = () => {
+ cleanup();
+ resolve(null);
+ };
+
+ modal.classList.remove("hidden");
+ });
+}
+
+async function loadConfig() {
+ const { data } = await fetchJsonWithRetry("/api/config", {}, {
+ retries: 2,
+ timeoutMs: 10000,
+ retryDelayMs: 420,
+ retryStatuses: [408, 425, 500, 502, 503, 504],
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "加载配置失败");
+ }
+
+ const c = data.config;
+ const delivery = c && typeof c === "object" ? c.image_delivery || {} : {};
+ const maxKbps = Number(delivery.max_kbps);
+ const mobile = isMobileClient();
+ let previewConcurrency = mobile ? 1 : 3;
+ if (!mobile && Number.isFinite(maxKbps)) {
+ if (maxKbps <= 0) {
+ previewConcurrency = 4;
+ } else if (maxKbps <= 200) {
+ previewConcurrency = 2;
+ } else if (maxKbps <= 400) {
+ previewConcurrency = 3;
+ } else {
+ previewConcurrency = 4;
+ }
+ }
+ state.previewLoadConcurrency = Math.max(1, Math.min(4, previewConcurrency));
+ state.imageDeliveryMaxKbps = Number.isFinite(maxKbps) ? maxKbps : 300;
+
+ const div = document.getElementById("config-summary");
+ div.innerHTML = [
+ `模板文件:${c.template_file || "(未配置)"}
`,
+ `输出目录:${c.output_dir || "(未配置)"}
`,
+ `触发关键词:${c.trigger_keyword || "#接龙"}
`,
+ `历史条数:${data.history_count}
`,
+ `今日日志:${data.review_log_count || 0}
`,
+ `活跃标识:${data.active_issue_count || 0}
`,
+ ].join("");
+
+ if (!getTemplateFile()) {
+ document.getElementById("template-file").value = c.template_file || "";
+ }
+ if (!getOutputDir()) {
+ document.getElementById("output-dir").value = c.output_dir || "";
+ }
+ return data;
+}
+
+async function parseOnly(insuranceYear = null) {
+ const rawText = getRawText();
+ if (!rawText) {
+ setMsg("请先输入接龙文本", true);
+ return;
+ }
+
+ setMsg("解析中...");
+ setLoading(true);
+ try {
+ const { data } = await postJson("/api/parse", {
+ raw_text: rawText,
+ insurance_year: insuranceYear,
+ });
+
+ if (!data.ok) {
+ throw new Error(data.error || "解析失败");
+ }
+
+ renderResult(data.result);
+ if (data.result.needs_insurance_choice) {
+ setMsg("检测到保险未写年限,生成时会要求选择3年交或5年交。", false);
+ } else {
+ setMsg(
+ `完成:有效 ${data.result.summary.parsed} 条,新增 ${data.result.summary.new} 条,重复 ${data.result.summary.duplicate} 条。`
+ );
+ }
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function attemptRecoverGenerationAfterNetworkIssue(progressToken) {
+ const token = String(progressToken || "").trim();
+ if (!token) {
+ return false;
+ }
+ const deadline = Date.now() + 45000;
+ let seenProgress = false;
+
+ while (Date.now() < deadline) {
+ const progress = await fetchProgressOnce(token);
+ if (progress) {
+ seenProgress = true;
+ if (progress.status === "done") {
+ stopProgressPolling();
+ try {
+ await loadConfig();
+ await loadHistoryView();
+ } catch (err) {
+ void err;
+ }
+ setMsg("网络波动,任务已在后台完成,已自动刷新历史。");
+ return true;
+ }
+ if (progress.status === "error") {
+ const detail = progress.error || progress.detail || "生成过程异常";
+ throw new Error(detail);
+ }
+ }
+ await sleepMs(1000);
+ }
+
+ if (seenProgress) {
+ setMsg("网络波动,任务可能仍在后台执行,请稍后查看历史记录。");
+ return true;
+ }
+ return false;
+}
+
+async function generateOnly() {
+ const rawText = getRawText();
+ if (!rawText) {
+ setMsg("请先输入接龙文本", true);
+ return;
+ }
+
+ const templateFile = getTemplateFile();
+ const outputDir = getOutputDir();
+ const progressToken = `${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
+
+ setMsg("生成中...");
+ setProgressVisible(true);
+ renderProgress({
+ stage: "提交任务",
+ percent: 1,
+ detail: "请求已发出",
+ status: "running",
+ });
+ startProgressPolling(progressToken);
+ setLoading(true);
+
+ let insuranceYear = null;
+ let insuranceYearChoices = {};
+
+ try {
+ for (let i = 0; i < 2; i += 1) {
+ let status = 0;
+ let data = {};
+ try {
+ const resp = await postJson(
+ "/api/generate",
+ {
+ raw_text: rawText,
+ insurance_year: insuranceYear,
+ insurance_year_choices: insuranceYearChoices,
+ progress_token: progressToken,
+ template_file: templateFile || undefined,
+ output_dir: outputDir || undefined,
+ save_history: true,
+ },
+ {
+ retries: 2,
+ timeoutMs: 60000,
+ retryDelayMs: 650,
+ retryStatuses: [408, 425, 500, 502, 503, 504],
+ }
+ );
+ status = resp.status;
+ data = resp.data || {};
+ } catch (err) {
+ const recovered = await attemptRecoverGenerationAfterNetworkIssue(progressToken);
+ if (recovered) {
+ return;
+ }
+ throw new Error("网络波动导致生成请求失败,请重试。");
+ }
+
+ if (data.progress_token) {
+ state.activeProgressToken = data.progress_token;
+ }
+ await fetchProgressOnce(state.activeProgressToken || progressToken);
+
+ if (data.ok) {
+ if (data.result) {
+ renderResult(data.result);
+ }
+
+ if (data.generated_count > 0) {
+ const downloadImages = data.download_images || [];
+ warmGeneratedImages(downloadImages);
+ state.lastGeneratedImages = downloadImages;
+ renderPreview(downloadImages);
+ updateDownloadButtonState(false);
+ setMsg(`生成完成:${data.generated_count} 张,可在预览区逐张复制/下载。`);
+ } else {
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ setMsg(data.message || "没有可生成的新记录");
+ }
+ renderProgress({
+ stage: "完成",
+ percent: 100,
+ detail: `生成结束,产出 ${data.generated_count || 0} 张`,
+ status: "done",
+ });
+ stopProgressPolling();
+
+ await loadConfig();
+ await loadHistoryView();
+ return;
+ }
+
+ if (status === 400 && data.error_code === "insurance_year_required") {
+ renderProgress({
+ stage: "等待选择",
+ percent: 15,
+ detail: "保险年限待选择(请逐条选择3年交/5年交)",
+ status: "need_input",
+ });
+ if (data.result) {
+ renderResult(data.result);
+ }
+ const pendingRows = (data.result && data.result.pending_insurance_records) || [];
+ const selected = await askInsuranceYears(pendingRows);
+ if (!selected) {
+ setMsg("你已取消保险年限选择,本次未生成。", true);
+ return;
+ }
+ insuranceYear = null;
+ insuranceYearChoices = selected;
+ continue;
+ }
+
+ if (status === 429 && data.error_code === "generate_busy") {
+ const recovered = await attemptRecoverGenerationAfterNetworkIssue(progressToken);
+ if (recovered) {
+ return;
+ }
+ throw new Error("系统正在处理上一个生成任务,请稍后再试。");
+ }
+
+ throw new Error(data.error || data.message || "生成失败");
+ }
+
+ throw new Error("保险年限选择后仍未生成,请重试");
+ } catch (err) {
+ renderProgress({
+ stage: "失败",
+ percent: 100,
+ detail: err?.message || String(err),
+ status: "error",
+ error: err?.message || String(err),
+ });
+ stopProgressPolling();
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function downloadAllGenerated() {
+ const items = state.lastGeneratedImages || [];
+ if (!Array.isArray(items) || items.length === 0) {
+ setMsg("暂无可下载图片,请先生成。", true);
+ return;
+ }
+
+ setMsg(`开始下载:${items.length} 张...`);
+ setLoading(true);
+ try {
+ await triggerMultiDownloads(items);
+ setMsg(`下载完成:${items.length} 张。`);
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function clearHistory() {
+ const ok = window.confirm("确认清空历史记录?");
+ if (!ok) {
+ return;
+ }
+
+ setLoading(true);
+ setMsg("清空中...");
+ try {
+ const { data } = await postJson("/api/history/clear", {});
+ if (!data.ok) {
+ throw new Error(data.error || "清空失败");
+ }
+ setMsg("历史已清空");
+ await loadConfig();
+ await loadHistoryView();
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function forceClearOutput() {
+ const ok = window.confirm("确认强制清理已生成截图与任务文件?这不会清空历史记录。");
+ if (!ok) {
+ return;
+ }
+
+ setLoading(true);
+ setMsg("清理截图中...");
+ try {
+ const { data } = await postJson("/api/output/clear", {});
+ if (!data.ok) {
+ throw new Error(data.error || "清理失败");
+ }
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ await loadHistoryView();
+ setProgressVisible(false);
+ stopProgressPolling();
+ setMsg(
+ `清理完成:删除任务目录 ${data.removed_dirs || 0} 个,删除文件 ${data.removed_files || 0} 个。`
+ );
+ } finally {
+ setLoading(false);
+ }
+}
+
+async function pasteToRawText() {
+ if (!navigator.clipboard || !navigator.clipboard.readText) {
+ setMsg("当前环境不支持一键粘贴,请使用 Ctrl+V(或 Command+V)。", true);
+ return;
+ }
+ try {
+ const text = await navigator.clipboard.readText();
+ if (!text) {
+ setMsg("剪贴板为空。", true);
+ return;
+ }
+ setRawText(text);
+ setMsg("已粘贴到输入框。");
+ } catch (err) {
+ setMsg("粘贴失败,请检查浏览器剪贴板权限后重试。", true);
+ }
+}
+
+function bindEvents() {
+ document.getElementById("paste-btn").addEventListener("click", async () => {
+ try {
+ await pasteToRawText();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("parse-btn").addEventListener("click", async () => {
+ try {
+ await parseOnly();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("generate-btn").addEventListener("click", async () => {
+ try {
+ await generateOnly();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("force-clear-btn").addEventListener("click", async () => {
+ try {
+ await forceClearOutput();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("history-refresh-btn").addEventListener("click", async () => {
+ try {
+ await loadHistoryView(true);
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("issue-refresh-btn").addEventListener("click", async () => {
+ try {
+ await loadIssueMarks(true);
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ document.getElementById("clear-btn").addEventListener("click", async () => {
+ try {
+ await clearHistory();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+
+ const corrModal = document.getElementById("correction-modal");
+ const corrRemember = document.getElementById("corr-remember");
+ const corrKeywordWrap = document.getElementById("corr-keyword-wrap");
+
+ document.getElementById("corr-cancel").addEventListener("click", () => {
+ closeCorrectionModal();
+ });
+ document.getElementById("corr-submit").addEventListener("click", async () => {
+ try {
+ await applyCorrection();
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+ });
+ corrRemember.addEventListener("change", () => {
+ corrKeywordWrap.classList.toggle("hidden", !corrRemember.checked);
+ });
+ corrModal.addEventListener("click", (ev) => {
+ if (ev.target === corrModal) {
+ closeCorrectionModal();
+ }
+ });
+}
+
+async function init() {
+ bindEvents();
+ state.lastGeneratedImages = [];
+ renderPreview([]);
+ updateDownloadButtonState(false);
+ try {
+ await loadConfig();
+ await loadHistoryView();
+ await loadIssueMarks();
+ setMsg("可直接粘贴接龙并生成。", false);
+ } catch (err) {
+ setMsg(err.message || String(err), true);
+ }
+}
+
+init();
+
+(function mountVueShell() {
+ const shellRoot = document.getElementById("vue-shell");
+ if (!shellRoot || !window.Vue || typeof window.Vue.createApp !== "function") {
+ return;
+ }
+
+ const { createApp } = window.Vue;
+ createApp({
+ data() {
+ return {
+ loading: false,
+ error: "",
+ historyCount: 0,
+ activeIssueCount: 0,
+ reviewLogCount: 0,
+ updatedAt: "",
+ };
+ },
+ computed: {
+ statusText() {
+ if (this.loading) {
+ return "同步中";
+ }
+ return this.error ? "异常" : "正常";
+ },
+ },
+ methods: {
+ async refreshMeta(showMsg) {
+ this.loading = true;
+ this.error = "";
+ try {
+ const { data } = await fetchJsonWithRetry("/api/config", {}, {
+ retries: 2,
+ timeoutMs: 10000,
+ retryDelayMs: 420,
+ retryStatuses: [408, 425, 500, 502, 503, 504],
+ });
+ if (!data.ok) {
+ throw new Error(data.error || "加载配置失败");
+ }
+ this.historyCount = Number(data.history_count || 0);
+ this.activeIssueCount = Number(data.active_issue_count || 0);
+ this.reviewLogCount = Number(data.review_log_count || 0);
+ this.updatedAt = new Date().toLocaleTimeString("zh-CN", { hour12: false });
+ if (showMsg && typeof window.showToast === "function") {
+ window.showToast("状态已刷新");
+ }
+ } catch (err) {
+ this.error = err?.message || String(err);
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+ mounted() {
+ void this.refreshMeta(false);
+ },
+ template: `
+
+
+
前端迁移状态(Vue)
+ {{ statusText }}
+
+
+
历史条数{{ historyCount }}
+
活跃标识{{ activeIssueCount }}
+
今日日志{{ reviewLogCount }}
+
更新时间{{ updatedAt || '-' }}
+
+
+
+ {{ error }}
+
+
+ `,
+ }).mount("#vue-shell");
+})();
diff --git a/app/static/styles.css b/app/static/styles.css
new file mode 100644
index 0000000..76f7eb2
--- /dev/null
+++ b/app/static/styles.css
@@ -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;
+ }
+}
diff --git a/app/static/vendor/vue.global.prod.js b/app/static/vendor/vue.global.prod.js
new file mode 100644
index 0000000..df4abb3
--- /dev/null
+++ b/app/static/vendor/vue.global.prod.js
@@ -0,0 +1,13 @@
+/**
+* vue v3.5.29
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/var Vue=function(e){"use strict";var t,n,r;let i,l,s,o,a,c,u,d,h,p,f,g,m;function y(e){let t=Object.create(null);for(let n of e.split(","))t[n]=1;return e=>e in t}let b={},_=[],S=()=>{},x=()=>!1,C=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||97>e.charCodeAt(2)),k=e=>e.startsWith("onUpdate:"),T=Object.assign,w=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},N=Object.prototype.hasOwnProperty,A=(e,t)=>N.call(e,t),E=Array.isArray,I=e=>"function"==typeof e,R=e=>"string"==typeof e,O=e=>"symbol"==typeof e,M=e=>null!==e&&"object"==typeof e,P=e=>(M(e)||I(e))&&I(e.then)&&I(e.catch),L=Object.prototype.toString,$=e=>R(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,F=y(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),D=y("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),V=e=>{let t=Object.create(null);return n=>t[n]||(t[n]=e(n))},j=/-\w/g,B=V(e=>e.replace(j,e=>e.slice(1).toUpperCase())),U=/\B([A-Z])/g,H=V(e=>e.replace(U,"-$1").toLowerCase()),q=V(e=>e.charAt(0).toUpperCase()+e.slice(1)),W=V(e=>e?`on${q(e)}`:""),K=(e,t)=>!Object.is(e,t),z=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},G=e=>{let t=parseFloat(e);return isNaN(t)?e:t},X=e=>{let t=R(e)?Number(e):NaN;return isNaN(t)?e:t},Q=()=>i||(i="u">typeof globalThis?globalThis:"u">typeof self?self:"u">typeof window?window:"u">typeof global?global:{}),Z=y("Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol");function Y(e){if(E(e)){let t={};for(let n=0;n{if(e){let n=e.split(et);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function ei(e){let t="";if(R(e))t=e;else if(E(e))for(let n=0;neu(e,t))}let eh=e=>!!(e&&!0===e.__v_isRef),ep=e=>R(e)?e:null==e?"":E(e)||M(e)&&(e.toString===L||!I(e.toString))?eh(e)?ep(e.value):JSON.stringify(e,ef,2):String(e),ef=(e,t)=>{let n;if(eh(t))return ef(e,t.value);if("[object Map]"===(n=t,L.call(n)))return{[`Map(${t.size})`]:[...t.entries()].reduce((e,[t,n],r)=>(e[eg(t,r)+" =>"]=n,e),{})};{let e;if("[object Set]"===(e=t,L.call(e)))return{[`Set(${t.size})`]:[...t.values()].map(e=>eg(e))};else{if(O(t))return eg(t);let e;if(M(t)&&!E(t)&&"[object Object]"!==(e=t,L.call(e)))return String(t)}}return t},eg=(e,t="")=>{var n;return O(e)?`Symbol(${null!=(n=e.description)?n:t})`:e};class em{constructor(e=!1){this.detached=e,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=l,!e&&l&&(this.index=(l.scopes||(l.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){let e,t;if(this._isPaused=!0,this.scopes)for(e=0,t=this.scopes.length;e0&&0==--this._on&&(l=this.prevScope,this.prevScope=void 0)}stop(e){if(this._active){let t,n;for(t=0,this._active=!1,n=this.effects.length;t0)){if(a){let e=a;for(a=void 0;e;){let t=e.next;e.next=void 0,e.flags&=-9,e=t}}for(;o;){let t=o;for(o=void 0;t;){let n=t.next;if(t.next=void 0,t.flags&=-9,1&t.flags)try{t.trigger()}catch(t){e||(e=t)}t=n}}if(e)throw e}}function ex(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function eC(e){let t,n=e.depsTail,r=n;for(;r;){let e=r.prevDep;-1===r.version?(r===n&&(n=e),ew(r),function(e){let{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=e}e.deps=t,e.depsTail=n}function ek(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(eT(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function eT(e){if(4&e.flags&&!(16&e.flags)||(e.flags&=-17,e.globalVersion===eO)||(e.globalVersion=eO,!e.isSSR&&128&e.flags&&(!e.deps&&!e._dirty||!ek(e))))return;e.flags|=2;let t=e.dep,n=s,r=eN;s=e,eN=!0;try{ex(e);let n=e.fn(e._value);(0===t.version||K(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(e){throw t.version++,e}finally{s=n,eN=r,eC(e),e.flags&=-3}}function ew(e,t=!1){let{dep:n,prevSub:r,nextSub:i}=e;if(r&&(r.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let e=n.computed.deps;e;e=e.nextDep)ew(e,!0)}t||--n.sc||!n.map||n.map.delete(n.key)}let eN=!0,eA=[];function eE(){eA.push(eN),eN=!1}function eI(){let e=eA.pop();eN=void 0===e||e}function eR(e){let{cleanup:t}=e;if(e.cleanup=void 0,t){let e=s;s=void 0;try{t()}finally{s=e}}}let eO=0;class eM{constructor(e,t){this.sub=e,this.dep=t,this.version=t.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class eP{constructor(e){this.computed=e,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(e){if(!s||!eN||s===this.computed)return;let t=this.activeLink;if(void 0===t||t.sub!==s)t=this.activeLink=new eM(s,this),s.deps?(t.prevDep=s.depsTail,s.depsTail.nextDep=t,s.depsTail=t):s.deps=s.depsTail=t,function e(t){if(t.dep.sc++,4&t.sub.flags){let n=t.dep.computed;if(n&&!t.dep.subs){n.flags|=20;for(let t=n.deps;t;t=t.nextDep)e(t)}let r=t.dep.subs;r!==t&&(t.prevSub=r,r&&(r.nextSub=t)),t.dep.subs=t}}(t);else if(-1===t.version&&(t.version=this.version,t.nextDep)){let e=t.nextDep;e.prevDep=t.prevDep,t.prevDep&&(t.prevDep.nextDep=e),t.prevDep=s.depsTail,t.nextDep=void 0,s.depsTail.nextDep=t,s.depsTail=t,s.deps===t&&(s.deps=e)}return t}trigger(e){this.version++,eO++,this.notify(e)}notify(e){eb++;try{for(let e=this.subs;e;e=e.prevSub)e.sub.notify()&&e.sub.dep.notify()}finally{eS()}}}let eL=new WeakMap,e$=Symbol(""),eF=Symbol(""),eD=Symbol("");function eV(e,t,n){if(eN&&s){let t=eL.get(e);t||eL.set(e,t=new Map);let r=t.get(n);r||(t.set(n,r=new eP),r.map=t,r.key=n),r.track()}}function ej(e,t,n,r,i,l){let s=eL.get(e);if(!s)return void eO++;let o=e=>{e&&e.trigger()};if(eb++,"clear"===t)s.forEach(o);else{let i=E(e),l=i&&$(n);if(i&&"length"===n){let e=Number(r);s.forEach((t,n)=>{("length"===n||n===eD||!O(n)&&n>=e)&&o(t)})}else switch((void 0!==n||s.has(void 0))&&o(s.get(n)),l&&o(s.get(eD)),t){case"add":if(i)l&&o(s.get("length"));else{let t;o(s.get(e$));"[object Map]"===(t=e,L.call(t))&&o(s.get(eF))}break;case"delete":if(!i){let t;o(s.get(e$));"[object Map]"===(t=e,L.call(t))&&o(s.get(eF))}break;case"set":let a;"[object Map]"===(a=e,L.call(a))&&o(s.get(e$))}}eS()}function eB(e){let t=tm(e);return t===e?t:(eV(t,"iterate",eD),tf(e)?t:t.map(ty))}function eU(e){return eV(e=tm(e),"iterate",eD),e}function eH(e,t){return tp(e)?th(e)?tb(ty(t)):tb(t):ty(t)}let eq={__proto__:null,[Symbol.iterator](){return eW(this,Symbol.iterator,e=>eH(this,e))},concat(...e){return eB(this).concat(...e.map(e=>E(e)?eB(e):e))},entries(){return eW(this,"entries",e=>(e[1]=eH(this,e[1]),e))},every(e,t){return ez(this,"every",e,t,void 0,arguments)},filter(e,t){return ez(this,"filter",e,t,e=>e.map(e=>eH(this,e)),arguments)},find(e,t){return ez(this,"find",e,t,e=>eH(this,e),arguments)},findIndex(e,t){return ez(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return ez(this,"findLast",e,t,e=>eH(this,e),arguments)},findLastIndex(e,t){return ez(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return ez(this,"forEach",e,t,void 0,arguments)},includes(...e){return eG(this,"includes",e)},indexOf(...e){return eG(this,"indexOf",e)},join(e){return eB(this).join(e)},lastIndexOf(...e){return eG(this,"lastIndexOf",e)},map(e,t){return ez(this,"map",e,t,void 0,arguments)},pop(){return eX(this,"pop")},push(...e){return eX(this,"push",e)},reduce(e,...t){return eJ(this,"reduce",e,t)},reduceRight(e,...t){return eJ(this,"reduceRight",e,t)},shift(){return eX(this,"shift")},some(e,t){return ez(this,"some",e,t,void 0,arguments)},splice(...e){return eX(this,"splice",e)},toReversed(){return eB(this).toReversed()},toSorted(e){return eB(this).toSorted(e)},toSpliced(...e){return eB(this).toSpliced(...e)},unshift(...e){return eX(this,"unshift",e)},values(){return eW(this,"values",e=>eH(this,e))}};function eW(e,t,n){let r=eU(e),i=r[t]();return r===e||tf(e)||(i._next=i.next,i.next=()=>{let e=i._next();return e.done||(e.value=n(e.value)),e}),i}let eK=Array.prototype;function ez(e,t,n,r,i,l){let s=eU(e),o=s!==e&&!tf(e),a=s[t];if(a!==eK[t]){let t=a.apply(e,l);return o?ty(t):t}let c=n;s!==e&&(o?c=function(t,r){return n.call(this,eH(e,t),r,e)}:n.length>2&&(c=function(t,r){return n.call(this,t,r,e)}));let u=a.call(s,c,r);return o&&i?i(u):u}function eJ(e,t,n,r){let i=eU(e),l=n;return i!==e&&(tf(e)?n.length>3&&(l=function(t,r,i){return n.call(this,t,r,i,e)}):l=function(t,r,i){return n.call(this,t,eH(e,r),i,e)}),i[t](l,...r)}function eG(e,t,n){let r=tm(e);eV(r,"iterate",eD);let i=r[t](...n);return(-1===i||!1===i)&&tg(n[0])?(n[0]=tm(n[0]),r[t](...n)):i}function eX(e,t,n=[]){eE(),eb++;let r=tm(e)[t].apply(e,n);return eS(),eI(),r}let eQ=y("__proto__,__v_isRef,__isVue"),eZ=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>"arguments"!==e&&"caller"!==e).map(e=>Symbol[e]).filter(O));function eY(e){O(e)||(e=String(e));let t=tm(this);return eV(t,"has",e),t.hasOwnProperty(e)}class e0{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){if("__v_skip"===t)return e.__v_skip;let r=this._isReadonly,i=this._isShallow;if("__v_isReactive"===t)return!r;if("__v_isReadonly"===t)return r;if("__v_isShallow"===t)return i;if("__v_raw"===t)return n===(r?i?to:ts:i?tl:ti).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;let l=E(e);if(!r){let e;if(l&&(e=eq[t]))return e;if("hasOwnProperty"===t)return eY}let s=Reflect.get(e,t,t_(e)?e:n);if((O(t)?eZ.has(t):eQ(t))||(r||eV(e,"get",t),i))return s;if(t_(s)){let e=l&&$(t)?s:s.value;return r&&M(e)?tu(e):e}return M(s)?r?tu(s):ta(s):s}}class e1 extends e0{constructor(e=!1){super(!1,e)}set(e,t,n,r){let i=e[t],l=E(e)&&$(t);if(!this._isShallow){let e=tp(i);if(tf(n)||tp(n)||(i=tm(i),n=tm(n)),!l&&t_(i)&&!t_(n))if(e)return!0;else return i.value=n,!0}let s=l?Number(t)e;function e9(e){return function(){return"delete"!==e&&("clear"===e?void 0:this)}}function e7(e,t){let n,r=(T(n={get(n){let r=this.__v_raw,i=tm(r),l=tm(n);e||(K(n,l)&&eV(i,"get",n),eV(i,"get",l));let{has:s}=Reflect.getPrototypeOf(i),o=t?e5:e?tb:ty;return s.call(i,n)?o(r.get(n)):s.call(i,l)?o(r.get(l)):void(r!==i&&r.get(n))},get size(){let t=this.__v_raw;return e||eV(tm(t),"iterate",e$),t.size},has(t){let n=this.__v_raw,r=tm(n),i=tm(t);return e||(K(t,i)&&eV(r,"has",t),eV(r,"has",i)),t===i?n.has(t):n.has(t)||n.has(i)},forEach(n,r){let i=this,l=i.__v_raw,s=tm(l),o=t?e5:e?tb:ty;return e||eV(s,"iterate",e$),l.forEach((e,t)=>n.call(r,o(e),o(t),i))}},e?{add:e9("add"),set:e9("set"),delete:e9("delete"),clear:e9("clear")}:{add(e){t||tf(e)||tp(e)||(e=tm(e));let n=tm(this);return Reflect.getPrototypeOf(n).has.call(n,e)||(n.add(e),ej(n,"add",e,e)),this},set(e,n){t||tf(n)||tp(n)||(n=tm(n));let r=tm(this),{has:i,get:l}=Reflect.getPrototypeOf(r),s=i.call(r,e);s||(e=tm(e),s=i.call(r,e));let o=l.call(r,e);return r.set(e,n),s?K(n,o)&&ej(r,"set",e,n):ej(r,"add",e,n),this},delete(e){let t=tm(this),{has:n,get:r}=Reflect.getPrototypeOf(t),i=n.call(t,e);i||(e=tm(e),i=n.call(t,e)),r&&r.call(t,e);let l=t.delete(e);return i&&ej(t,"delete",e,void 0),l},clear(){let e=tm(this),t=0!==e.size,n=e.clear();return t&&ej(e,"clear",void 0,void 0),n}}),["keys","values","entries",Symbol.iterator].forEach(r=>{n[r]=function(...n){let i,l=this.__v_raw,s=tm(l),o="[object Map]"===(i=s,L.call(i)),a="entries"===r||r===Symbol.iterator&&o,c=l[r](...n),u=t?e5:e?tb:ty;return e||eV(s,"iterate","keys"===r&&o?eF:e$),T(Object.create(c),{next(){let{value:e,done:t}=c.next();return t?{value:e,done:t}:{value:a?[u(e[0]),u(e[1])]:u(e),done:t}}})}}),n);return(t,n,i)=>"__v_isReactive"===n?!e:"__v_isReadonly"===n?e:"__v_raw"===n?t:Reflect.get(A(r,n)&&n in t?r:t,n,i)}let te={get:e7(!1,!1)},tt={get:e7(!1,!0)},tn={get:e7(!0,!1)},tr={get:e7(!0,!0)},ti=new WeakMap,tl=new WeakMap,ts=new WeakMap,to=new WeakMap;function ta(e){return tp(e)?e:td(e,!1,e6,te,ti)}function tc(e){return td(e,!1,e4,tt,tl)}function tu(e){return td(e,!0,e3,tn,ts)}function td(e,t,n,r,i){var l;let s;if(!M(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;let o=(l=e).__v_skip||!Object.isExtensible(l)?0:function(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}((s=l,L.call(s)).slice(8,-1));if(0===o)return e;let a=i.get(e);if(a)return a;let c=new Proxy(e,2===o?r:n);return i.set(e,c),c}function th(e){return tp(e)?th(e.__v_raw):!!(e&&e.__v_isReactive)}function tp(e){return!!(e&&e.__v_isReadonly)}function tf(e){return!!(e&&e.__v_isShallow)}function tg(e){return!!e&&!!e.__v_raw}function tm(e){let t=e&&e.__v_raw;return t?tm(t):e}function tv(e){return!A(e,"__v_skip")&&Object.isExtensible(e)&&J(e,"__v_skip",!0),e}let ty=e=>M(e)?ta(e):e,tb=e=>M(e)?tu(e):e;function t_(e){return!!e&&!0===e.__v_isRef}function tS(e){return tC(e,!1)}function tx(e){return tC(e,!0)}function tC(e,t){return t_(e)?e:new tk(e,t)}class tk{constructor(e,t){this.dep=new eP,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=t?e:tm(e),this._value=t?e:ty(e),this.__v_isShallow=t}get value(){return this.dep.track(),this._value}set value(e){let t=this._rawValue,n=this.__v_isShallow||tf(e)||tp(e);K(e=n?e:tm(e),t)&&(this._rawValue=e,this._value=n?e:ty(e),this.dep.trigger())}}function tT(e){return t_(e)?e.value:e}let tw={get:(e,t,n)=>"__v_raw"===t?e:tT(Reflect.get(e,t,n)),set:(e,t,n,r)=>{let i=e[t];return t_(i)&&!t_(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}};function tN(e){return th(e)?e:new Proxy(e,tw)}class tA{constructor(e){this.__v_isRef=!0,this._value=void 0;const t=this.dep=new eP,{get:n,set:r}=e(t.track.bind(t),t.trigger.bind(t));this._get=n,this._set=r}get value(){return this._value=this._get()}set value(e){this._set(e)}}function tE(e){return new tA(e)}class tI{constructor(e,t,n){this._object=e,this._key=t,this._defaultValue=n,this.__v_isRef=!0,this._value=void 0,this._raw=tm(e);let r=!0,i=e;if(!E(e)||!$(String(t)))do r=!tg(i)||tf(i);while(r&&(i=i.__v_raw));this._shallow=r}get value(){let e=this._object[this._key];return this._shallow&&(e=tT(e)),this._value=void 0===e?this._defaultValue:e}set value(e){if(this._shallow&&t_(this._raw[this._key])){let t=this._object[this._key];if(t_(t)){t.value=e;return}}this._object[this._key]=e}get dep(){var e,t;let n;return e=this._raw,t=this._key,(n=eL.get(e))&&n.get(t)}}class tR{constructor(e){this._getter=e,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}class tO{constructor(e,t,n){this.fn=e,this.setter=t,this._value=void 0,this.dep=new eP(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=eO-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!t,this.isSSR=n}notify(){if(this.flags|=16,!(8&this.flags)&&s!==this)return e_(this,!0),!0}get value(){let e=this.dep.track();return eT(this),e&&(e.version=this.dep.version),this._value}set value(e){this.setter&&this.setter(e)}}let tM={},tP=new WeakMap;function tL(e,t=!1,n=g){if(n){let t=tP.get(n);t||tP.set(n,t=[]),t.push(e)}}function t$(e,t=1/0,n){if(t<=0||!M(e)||e.__v_skip||((n=n||new Map).get(e)||0)>=t)return e;if(n.set(e,t),t--,t_(e))t$(e.value,t,n);else if(E(e))for(let r=0;r{t$(e,t,n)});else{let r;if("[object Object]"===(r=e,L.call(r))){for(let r in e)t$(e[r],t,n);for(let r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&t$(e[r],t,n)}}}return e}function tF(e,t,n,r){try{return r?e(...r):e()}catch(e){tV(e,t,n)}}function tD(e,t,n,r){if(I(e)){let i=tF(e,t,n,r);return i&&P(i)&&i.catch(e=>{tV(e,t,n)}),i}if(E(e)){let i=[];for(let l=0;l=tY(n)?tj.push(e):tj.splice(function(e){let t=tB+1,n=tj.length;for(;t>>1,i=tj[r],l=tY(i);ltY(e)-tY(t));if(tU.length=0,tH)return void tH.push(...e);for(tq=0,tH=e;tqnull==e.id?2&e.flags?-1:1/0:e.id,t0=null,t1=null;function t2(e){let t=t0;return t0=e,t1=e&&e.type.__scopeId||null,t}function t6(e,t=t0,n){if(!t||e._n)return e;let r=(...n)=>{let i;r._d&&is(-1);let l=t2(t);try{i=e(...n)}finally{t2(l),r._d&&is(1)}return i};return r._n=!0,r._c=!0,r._d=!0,r}function t3(e,t,n,r){let i=e.dirs,l=t&&t.dirs;for(let s=0;s1)return n&&I(t)?t.call(r&&r.proxy):t}}let t5=Symbol.for("v-scx");function t9(e,t){return t7(e,null,{flush:"sync"})}function t7(e,t,n=b){let{flush:r}=n,i=T({},n),s=iw;i.call=(e,t,n)=>tD(e,s,t,n);let o=!1;return"post"===r?i.scheduler=e=>{rW(e,s&&s.suspense)}:"sync"!==r&&(o=!0,i.scheduler=(e,t)=>{t?e():tJ(e)}),i.augmentJob=e=>{t&&(e.flags|=4),o&&(e.flags|=2,s&&(e.id=s.uid,e.i=s))},function(e,t,n=b){let r,i,s,o,{immediate:a,deep:c,once:u,scheduler:d,augmentJob:h,call:p}=n,f=e=>c?e:tf(e)||!1===c||0===c?t$(e,1):t$(e),m=!1,y=!1;if(t_(e)?(i=()=>e.value,m=tf(e)):th(e)?(i=()=>f(e),m=!0):E(e)?(y=!0,m=e.some(e=>th(e)||tf(e)),i=()=>e.map(e=>t_(e)?e.value:th(e)?f(e):I(e)?p?p(e,2):e():void 0)):i=I(e)?t?p?()=>p(e,2):e:()=>{if(s){eE();try{s()}finally{eI()}}let t=g;g=r;try{return p?p(e,3,[o]):e(o)}finally{g=t}}:S,t&&c){let e=i,t=!0===c?1/0:c;i=()=>t$(e(),t)}let _=l,x=()=>{r.stop(),_&&_.active&&w(_.effects,r)};if(u&&t){let e=t;t=(...t)=>{e(...t),x()}}let C=y?Array(e.length).fill(tM):tM,k=e=>{if(1&r.flags&&(r.dirty||e))if(t){let e=r.run();if(c||m||(y?e.some((e,t)=>K(e,C[t])):K(e,C))){s&&s();let n=g;g=r;try{let n=[e,C===tM?void 0:y&&C[0]===tM?[]:C,o];C=e,p?p(t,3,n):t(...n)}finally{g=n}}}else r.run()};return h&&h(k),(r=new ey(i)).scheduler=d?()=>d(k,!1):k,o=e=>tL(e,!1,r),s=r.onStop=()=>{let e=tP.get(r);if(e){if(p)p(e,4);else for(let t of e)t();tP.delete(r)}},t?a?k(!0):C=r.run():d?d(k.bind(null,!0),!0):r.run(),x.pause=r.pause.bind(r),x.resume=r.resume.bind(r),x.stop=x,x}(e,t,i)}function ne(e,t,n){let r,i=this.proxy,l=R(e)?e.includes(".")?nt(i,e):()=>i[e]:e.bind(i,i);I(t)?r=t:(r=t.handler,n=t);let s=iA(this),o=t7(l,r.bind(i),n);return s(),o}function nt(e,t){let n=t.split(".");return()=>{let t=e;for(let e=0;ee&&(e.disabled||""===e.disabled),ni=e=>e&&(e.defer||""===e.defer),nl=e=>"u">typeof SVGElement&&e instanceof SVGElement,ns=e=>"function"==typeof MathMLElement&&e instanceof MathMLElement,no=(e,t)=>{let n=e&&e.to;return R(n)?t?t(n):null:n},na={name:"Teleport",__isTeleport:!0,process(e,t,n,r,i,l,s,o,a,c){let{mc:u,pc:d,pbc:h,o:{insert:p,querySelector:f,createText:g}}=c,m=nr(t.props),{shapeFlag:y,children:b,dynamicChildren:_}=t;if(null==e){let e=t.el=g(""),c=t.anchor=g("");p(e,n,r),p(c,n,r);let d=(e,t)=>{16&y&&u(b,e,t,i,l,s,o,a)},h=()=>{let e=t.target=no(t.props,f),n=nd(e,t,g,p);e&&("svg"!==s&&nl(e)?s="svg":"mathml"!==s&&ns(e)&&(s="mathml"),i&&i.isCE&&(i.ce._teleportTargets||(i.ce._teleportTargets=new Set)).add(e),m||(d(e,n),nu(t,!1)))};m&&(d(n,c),nu(t,!0)),ni(t.props)?(t.el.__isMounted=!1,rW(()=>{h(),delete t.el.__isMounted},l)):h()}else{if(ni(t.props)&&!1===e.el.__isMounted)return void rW(()=>{na.process(e,t,n,r,i,l,s,o,a,c)},l);t.el=e.el,t.targetStart=e.targetStart;let u=t.anchor=e.anchor,p=t.target=e.target,g=t.targetAnchor=e.targetAnchor,y=nr(e.props),b=y?n:p,S=y?u:g;if("svg"===s||nl(p)?s="svg":("mathml"===s||ns(p))&&(s="mathml"),_?(h(e.dynamicChildren,_,b,i,l,s,o),rQ(e,t,!0)):a||d(e,t,b,S,i,l,s,o,!1),m)y?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):nc(t,n,u,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){let e=t.target=no(t.props,f);e&&nc(t,e,null,c,0)}else y&&nc(t,p,g,c,1);nu(t,m)}},remove(e,t,n,{um:r,o:{remove:i}},l){let{shapeFlag:s,children:o,anchor:a,targetStart:c,targetAnchor:u,target:d,props:h}=e;if(d&&(i(c),i(u)),l&&i(a),16&s){let e=l||!nr(h);for(let i=0;i{e.isMounted=!0}),n6(()=>{e.isUnmounting=!0}),e}let ng=[Function,Array],nm={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:ng,onEnter:ng,onAfterEnter:ng,onEnterCancelled:ng,onBeforeLeave:ng,onLeave:ng,onAfterLeave:ng,onLeaveCancelled:ng,onBeforeAppear:ng,onAppear:ng,onAfterAppear:ng,onAppearCancelled:ng},nv=e=>{let t=e.subTree;return t.component?nv(t.component):t};function ny(e){let t=e[0];if(e.length>1){for(let n of e)if(n.type!==r9){t=n;break}}return t}let nb={name:"BaseTransition",props:nm,setup(e,{slots:t}){let n=iN(),r=nf();return()=>{let i=t.default&&nT(t.default(),!0);if(!i||!i.length)return;let l=ny(i),s=tm(e),{mode:o}=s;if(r.isLeaving)return nx(l);let a=nC(l);if(!a)return nx(l);let c=nS(a,s,r,n,e=>c=e);a.type!==r9&&nk(a,c);let u=n.subTree&&nC(n.subTree);if(u&&u.type!==r9&&!iu(u,a)&&nv(n).type!==r9){let e=nS(u,s,r,n);if(nk(u,e),"out-in"===o&&a.type!==r9)return r.isLeaving=!0,e.afterLeave=()=>{r.isLeaving=!1,8&n.job.flags||n.update(),delete e.afterLeave,u=void 0},nx(l);"in-out"===o&&a.type!==r9?e.delayLeave=(e,t,n)=>{n_(r,u)[String(u.key)]=u,e[nh]=()=>{t(),e[nh]=void 0,delete c.delayedLeave,u=void 0},c.delayedLeave=()=>{n(),delete c.delayedLeave,u=void 0}}:u=void 0}else u&&(u=void 0);return l}}};function n_(e,t){let{leavingVNodes:n}=e,r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function nS(e,t,n,r,i){let{appear:l,mode:s,persisted:o=!1,onBeforeEnter:a,onEnter:c,onAfterEnter:u,onEnterCancelled:d,onBeforeLeave:h,onLeave:p,onAfterLeave:f,onLeaveCancelled:g,onBeforeAppear:m,onAppear:y,onAfterAppear:b,onAppearCancelled:_}=t,S=String(e.key),x=n_(n,e),C=(e,t)=>{e&&tD(e,r,9,t)},k=(e,t)=>{let n=t[1];C(e,t),E(e)?e.every(e=>e.length<=1)&&n():e.length<=1&&n()},T={mode:s,persisted:o,beforeEnter(t){let r=a;if(!n.isMounted)if(!l)return;else r=m||a;t[nh]&&t[nh](!0);let i=x[S];i&&iu(e,i)&&i.el[nh]&&i.el[nh](),C(r,[t])},enter(t){if(x[S]===e)return;let r=c,i=u,s=d;if(!n.isMounted)if(!l)return;else r=y||c,i=b||u,s=_||d;let o=!1;t[np]=e=>{o||(o=!0,e?C(s,[t]):C(i,[t]),T.delayedLeave&&T.delayedLeave(),t[np]=void 0)};let a=t[np].bind(null,!1);r?k(r,[t,a]):a()},leave(t,r){let i=String(e.key);if(t[np]&&t[np](!0),n.isUnmounting)return r();C(h,[t]);let l=!1;t[nh]=n=>{l||(l=!0,r(),n?C(g,[t]):C(f,[t]),t[nh]=void 0,x[i]===e&&delete x[i])};let s=t[nh].bind(null,!1);x[i]=e,p?k(p,[t,s]):s()},clone(e){let l=nS(e,t,n,r,i);return i&&i(l),l}};return T}function nx(e){if(nq(e))return(e=iv(e)).children=null,e}function nC(e){if(!nq(e))return e.type.__isTeleport&&e.children?ny(e.children):e;if(e.component)return e.component.subTree;let{shapeFlag:t,children:n}=e;if(n){if(16&t)return n[0];if(32&t&&I(n.default))return n.default()}}function nk(e,t){6&e.shapeFlag&&e.component?(e.transition=t,nk(e.component.subTree,t)):128&e.shapeFlag?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function nT(e,t=!1,n){let r=[],i=0;for(let l=0;l1)for(let e=0;enI(e,t&&(E(t)?t[l]:t),n,r,i));if(nU(r)&&!i){512&r.shapeFlag&&r.type.__asyncResolved&&r.component.subTree.component&&nI(e,t,n,r.component.subTree);return}let l=4&r.shapeFlag?iF(r.component):r.el,s=i?null:l,{i:o,r:a}=e,c=t&&t.r,u=o.refs===b?o.refs={}:o.refs,d=o.setupState,h=tm(d),p=d===b?x:e=>!nA(u,e)&&A(h,e),f=(e,t)=>!(t&&nA(u,t));if(null!=c&&c!==a&&(nR(t),R(c)?(u[c]=null,p(c)&&(d[c]=null)):t_(c)&&(f(c,t.k)&&(c.value=null),t.k&&(u[t.k]=null))),I(a))tF(a,o,12,[s,u]);else{let t=R(a),r=t_(a);if(t||r){let o=()=>{if(e.f){let n=t?p(a)?d[a]:u[a]:f()||!e.k?a.value:u[e.k];if(i)E(n)&&w(n,l);else if(E(n))n.includes(l)||n.push(l);else if(t)u[a]=[l],p(a)&&(d[a]=u[a]);else{let t=[l];f(a,e.k)&&(a.value=t),e.k&&(u[e.k]=t)}}else t?(u[a]=s,p(a)&&(d[a]=s)):r&&(f(a,e.k)&&(a.value=s),e.k&&(u[e.k]=s))};if(s){let t=()=>{o(),nE.delete(e)};t.id=-1,nE.set(e,t),rW(t,n)}else nR(e),o()}}}function nR(e){let t=nE.get(e);t&&(t.flags|=8,nE.delete(e))}let nO=!1,nM=()=>{nO||(console.error("Hydration completed but contains mismatches."),nO=!0)},nP=e=>{if(1===e.nodeType){if(e.namespaceURI.includes("svg")&&"foreignObject"!==e.tagName)return"svg";if(e.namespaceURI.includes("MathML"))return"mathml"}},nL=e=>8===e.nodeType;function n$(e){let{mt:t,p:n,o:{patchProp:r,createText:i,nextSibling:l,parentNode:s,remove:o,insert:a,createComment:c}}=e,u=(n,r,o,c,b,_=!1)=>{_=_||!!r.dynamicChildren;let S=nL(n)&&"["===n.data,x=()=>f(n,r,o,c,b,S),{type:C,ref:k,shapeFlag:T,patchFlag:w}=r,N=n.nodeType;r.el=n,-2===w&&(_=!1,r.dynamicChildren=null);let A=null;switch(C){case r5:3!==N?""===r.children?(a(r.el=i(""),s(n),n),A=n):A=x():(n.data!==r.children&&(nM(),n.data=r.children),A=l(n));break;case r9:y(n)?(A=l(n),m(r.el=n.content.firstChild,n,o)):A=8!==N||S?x():l(n);break;case r7:if(S&&(N=(n=l(n)).nodeType),1===N||3===N){A=n;let e=!r.children.length;for(let t=0;t{s=s||!!t.dynamicChildren;let{type:a,props:c,patchFlag:u,shapeFlag:d,dirs:p,transition:f}=t,g="input"===a||"option"===a;if(g||-1!==u){let a;p&&t3(t,null,n,"created");let b=!1;if(y(e)){b=rX(null,f)&&n&&n.vnode.props&&n.vnode.props.appear;let r=e.content.firstChild;if(b){let e=r.getAttribute("class");e&&(r.$cls=e),f.beforeEnter(r)}m(r,e,n),t.el=e=r}if(16&d&&!(c&&(c.innerHTML||c.textContent))){let r=h(e.firstChild,t,e,n,i,l,s);for(;r;){nV(e,1)||nM();let t=r;r=r.nextSibling,o(t)}}else if(8&d){let n=t.children;`
+`===n[0]&&("PRE"===e.tagName||"TEXTAREA"===e.tagName)&&(n=n.slice(1));let{textContent:r}=e;r!==n&&r!==n.replace(/\r\n|\r/g,`
+`)&&(nV(e,0)||nM(),e.textContent=t.children)}if(c){if(g||!s||48&u){let t=e.tagName.includes("-");for(let i in c)(g&&(i.endsWith("value")||"indeterminate"===i)||C(i)&&!F(i)||"."===i[0]||t&&!F(i))&&r(e,i,null,c[i],void 0,n)}else if(c.onClick)r(e,"onClick",null,c.onClick,void 0,n);else if(4&u&&th(c.style))for(let e in c.style)c.style[e]}(a=c&&c.onVnodeBeforeMount)&&iC(a,n,t),p&&t3(t,null,n,"beforeMount"),((a=c&&c.onVnodeMounted)||p||b)&&r3(()=>{a&&iC(a,n,t),b&&f.enter(e),p&&t3(t,null,n,"mounted")},i)}return e.nextSibling},h=(e,t,r,s,o,c,d)=>{d=d||!!t.dynamicChildren;let h=t.children,p=h.length;for(let t=0;t{let{slotScopeIds:u}=t;u&&(i=i?i.concat(u):u);let d=s(e),p=h(l(e),t,d,n,r,i,o);return p&&nL(p)&&"]"===p.data?l(t.anchor=p):(nM(),a(t.anchor=c("]"),d,p),p)},f=(e,t,r,i,a,c)=>{if(nV(e.parentElement,1)||nM(),t.el=null,c){let t=g(e);for(;;){let n=l(e);if(n&&n!==t)o(n);else break}}let u=l(e),d=s(e);return o(e),n(null,t,d,u,r,i,nP(d),a),r&&(r.vnode.el=t.el,rO(r,t.el)),u},g=(e,t="[",n="]")=>{let r=0;for(;e;)if((e=l(e))&&nL(e)&&(e.data===t&&r++,e.data===n))if(0===r)return l(e);else r--;return e},m=(e,t,n)=>{let r=t.parentNode;r&&r.replaceChild(e,t);let i=n;for(;i;)i.vnode.el===t&&(i.vnode.el=i.subTree.el=e),i=i.parent},y=e=>1===e.nodeType&&"TEMPLATE"===e.tagName;return[(e,t)=>{if(!t.hasChildNodes()){n(null,e,t),tZ(),t._vnode=e;return}u(t.firstChild,e,null,null,null),tZ(),t._vnode=e},u]}let nF="data-allow-mismatch",nD={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function nV(e,t){if(0===t||1===t)for(;e&&!e.hasAttribute(nF);)e=e.parentElement;let n=e&&e.getAttribute(nF);if(null==n)return!1;{if(""===n)return!0;let e=n.split(",");return!!(0===t&&e.includes("children"))||e.includes(nD[t])}}let nj=Q().requestIdleCallback||(e=>setTimeout(e,1)),nB=Q().cancelIdleCallback||(e=>clearTimeout(e)),nU=e=>!!e.type.__asyncLoader;function nH(e,t){let{ref:n,props:r,children:i,ce:l}=t.vnode,s=ig(e,r,i);return s.ref=n,s.ce=l,delete t.vnode.ce,s}let nq=e=>e.type.__isKeepAlive;function nW(e,t){let n;if(E(e))return e.some(e=>nW(e,t));if(R(e))return e.split(",").includes(t);return"[object RegExp]"===(n=e,L.call(n))&&(e.lastIndex=0,e.test(t))}function nK(e,t){nJ(e,"a",t)}function nz(e,t){nJ(e,"da",t)}function nJ(e,t,n=iw){let r=e.__wdc||(e.__wdc=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()});if(nQ(t,r,n),n){let e=n.parent;for(;e&&e.parent;)nq(e.parent.vnode)&&function(e,t,n,r){let i=nQ(t,e,r,!0);n3(()=>{w(r[t],i)},n)}(r,t,n,e),e=e.parent}}function nG(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function nX(e){return 128&e.shapeFlag?e.ssContent:e}function nQ(e,t,n=iw,r=!1){if(n){let i=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...r)=>{eE();let i=iA(n),l=tD(t,n,e,r);return i(),eI(),l});return r?i.unshift(l):i.push(l),l}}let nZ=e=>(t,n=iw)=>{iR&&"sp"!==e||nQ(e,(...e)=>t(...e),n)},nY=nZ("bm"),n0=nZ("m"),n1=nZ("bu"),n2=nZ("u"),n6=nZ("bum"),n3=nZ("um"),n4=nZ("sp"),n8=nZ("rtg"),n5=nZ("rtc");function n9(e,t=iw){nQ("ec",e,t)}let n7="components",re=Symbol.for("v-ndc");function rt(e,t,n=!0,r=!1){let i=t0||iw;if(i){let n=i.type;if(e===n7){let e=iD(n,!1);if(e&&(e===t||e===B(t)||e===q(B(t))))return n}let l=rn(i[e]||n[e],t)||rn(i.appContext[e],t);return!l&&r?n:l}}function rn(e,t){return e&&(e[t]||e[B(t)]||e[q(B(t))])}let rr=e=>e?iI(e)?iF(e):rr(e.parent):null,ri=T(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>rr(e.parent),$root:e=>rr(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>rh(e),$forceUpdate:e=>e.f||(e.f=()=>{tJ(e.update)}),$nextTick:e=>e.n||(e.n=tz.bind(e.proxy)),$watch:e=>ne.bind(e)}),rl=(e,t)=>e!==b&&!e.__isScriptSetup&&A(e,t),rs={get({_:e},t){let n,r;if("__v_skip"===t)return!0;let{ctx:i,setupState:l,data:s,props:o,accessCache:a,type:c,appContext:u}=e;if("$"!==t[0]){let e=a[t];if(void 0!==e)switch(e){case 1:return l[t];case 2:return s[t];case 4:return i[t];case 3:return o[t]}else{if(rl(l,t))return a[t]=1,l[t];if(s!==b&&A(s,t))return a[t]=2,s[t];if(A(o,t))return a[t]=3,o[t];if(i!==b&&A(i,t))return a[t]=4,i[t];ru&&(a[t]=0)}}let d=ri[t];return d?("$attrs"===t&&eV(e.attrs,"get",""),d(e)):(n=c.__cssModules)&&(n=n[t])?n:i!==b&&A(i,t)?(a[t]=4,i[t]):A(r=u.config.globalProperties,t)?r[t]:void 0},set({_:e},t,n){let{data:r,setupState:i,ctx:l}=e;return rl(i,t)?(i[t]=n,!0):r!==b&&A(r,t)?(r[t]=n,!0):!A(e.props,t)&&!("$"===t[0]&&t.slice(1)in e)&&(l[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:i,props:l,type:s}},o){let a;return!!(n[o]||e!==b&&"$"!==o[0]&&A(e,o)||rl(t,o)||A(l,o)||A(r,o)||A(ri,o)||A(i.config.globalProperties,o)||(a=s.__cssModules)&&a[o])},defineProperty(e,t,n){return null!=n.get?e._.accessCache[t]=0:A(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}},ro=T({},rs,{get(e,t){if(t!==Symbol.unscopables)return rs.get(e,t,e)},has:(e,t)=>"_"!==t[0]&&!Z(t)});function ra(e){let t=iN();return t.setupContext||(t.setupContext=i$(t))}function rc(e){return E(e)?e.reduce((e,t)=>(e[t]=null,e),{}):e}let ru=!0;function rd(e,t,n){tD(E(e)?e.map(e=>e.bind(t.proxy)):e.bind(t.proxy),t,n)}function rh(e){let t,n=e.type,{mixins:r,extends:i}=n,{mixins:l,optionsCache:s,config:{optionMergeStrategies:o}}=e.appContext,a=s.get(n);return a?t=a:l.length||r||i?(t={},l.length&&l.forEach(e=>rp(t,e,o,!0)),rp(t,n,o)):t=n,M(n)&&s.set(n,t),t}function rp(e,t,n,r=!1){let{mixins:i,extends:l}=t;for(let s in l&&rp(e,l,n,!0),i&&i.forEach(t=>rp(e,t,n,!0)),t)if(r&&"expose"===s);else{let r=rf[s]||n&&n[s];e[s]=r?r(e[s],t[s]):t[s]}return e}let rf={data:rg,props:rb,emits:rb,methods:ry,computed:ry,beforeCreate:rv,created:rv,beforeMount:rv,mounted:rv,beforeUpdate:rv,updated:rv,beforeDestroy:rv,beforeUnmount:rv,destroyed:rv,unmounted:rv,activated:rv,deactivated:rv,errorCaptured:rv,serverPrefetch:rv,components:ry,directives:ry,watch:function(e,t){if(!e)return t;if(!t)return e;let n=T(Object.create(null),e);for(let r in t)n[r]=rv(e[r],t[r]);return n},provide:rg,inject:function(e,t){return ry(rm(e),rm(t))}};function rg(e,t){return t?e?function(){return T(I(e)?e.call(this,this):e,I(t)?t.call(this,this):t)}:t:e}function rm(e){if(E(e)){let t={};for(let n=0;n"modelValue"===t||"model-value"===t?e.modelModifiers:e[`${t}Modifiers`]||e[`${B(t)}Modifiers`]||e[`${H(t)}Modifiers`];function rk(e,t,...n){let r;if(e.isUnmounted)return;let i=e.vnode.props||b,l=n,s=t.startsWith("update:"),o=s&&rC(i,t.slice(7));o&&(o.trim&&(l=n.map(e=>R(e)?e.trim():e)),o.number&&(l=n.map(G)));let a=i[r=W(t)]||i[r=W(B(t))];!a&&s&&(a=i[r=W(H(t))]),a&&tD(a,e,6,l);let c=i[r+"Once"];if(c){if(e.emitted){if(e.emitted[r])return}else e.emitted={};e.emitted[r]=!0,tD(c,e,6,l)}}let rT=new WeakMap;function rw(e,t){return!!e&&!!C(t)&&(A(e,(t=t.slice(2).replace(/Once$/,""))[0].toLowerCase()+t.slice(1))||A(e,H(t))||A(e,t))}function rN(e){let t,n,{type:r,vnode:i,proxy:l,withProxy:s,propsOptions:[o],slots:a,attrs:c,emit:u,render:d,renderCache:h,props:p,data:f,setupState:g,ctx:m,inheritAttrs:y}=e,b=t2(e);try{if(4&i.shapeFlag){let e=s||l;t=ib(d.call(e,e,h,p,g,f,m)),n=c}else t=ib(r.length>1?r(p,{attrs:c,slots:a,emit:u}):r(p,null)),n=r.props?c:rA(c)}catch(n){ie.length=0,tV(n,e,1),t=ig(r9)}let _=t;if(n&&!1!==y){let e=Object.keys(n),{shapeFlag:t}=_;e.length&&7&t&&(o&&e.some(k)&&(n=rE(n,o)),_=iv(_,n,!1,!0))}return i.dirs&&((_=iv(_,null,!1,!0)).dirs=_.dirs?_.dirs.concat(i.dirs):i.dirs),i.transition&&nk(_,i.transition),t=_,t2(b),t}let rA=e=>{let t;for(let n in e)("class"===n||"style"===n||C(n))&&((t||(t={}))[n]=e[n]);return t},rE=(e,t)=>{let n={};for(let r in e)k(r)&&r.slice(9)in t||(n[r]=e[r]);return n};function rI(e,t,n){let r=Object.keys(t);if(r.length!==Object.keys(e).length)return!0;for(let i=0;iObject.getPrototypeOf(e)===rM;function rL(e,t,n,r){let i,[l,s]=e.propsOptions,o=!1;if(t)for(let a in t){let c;if(F(a))continue;let u=t[a];l&&A(l,c=B(a))?s&&s.includes(c)?(i||(i={}))[c]=u:n[c]=u:rw(e.emitsOptions,a)||a in r&&u===r[a]||(r[a]=u,o=!0)}if(s){let t=tm(n),r=i||b;for(let i=0;i"_"===e||"_ctx"===e||"$stable"===e,rj=e=>E(e)?e.map(ib):[ib(e)],rB=(e,t,n)=>{if(t._n)return t;let r=t6((...e)=>rj(t(...e)),n);return r._c=!1,r},rU=(e,t,n)=>{let r=e._ctx;for(let n in e){if(rV(n))continue;let i=e[n];if(I(i))t[n]=rB(n,i,r);else if(null!=i){let e=rj(i);t[n]=()=>e}}},rH=(e,t)=>{let n=rj(t);e.slots.default=()=>n},rq=(e,t,n)=>{for(let r in t)(n||!rV(r))&&(e[r]=t[r])},rW=r3;function rK(e){return rz(e,n$)}function rz(e,t){var n;let r,i;Q().__VUE__=!0;let{insert:l,remove:s,patchProp:o,createElement:a,createText:c,createComment:d,setText:h,setElementText:p,parentNode:f,nextSibling:g,setScopeId:m=S,insertStaticContent:y}=e,x=(e,t,n,r=null,i=null,l=null,s,o=null,a=!!t.dynamicChildren)=>{if(e===t)return;e&&!iu(e,t)&&(r=es(e),et(e,i,l,!0),e=null),-2===t.patchFlag&&(a=!1,t.dynamicChildren=null);let{type:c,ref:u,shapeFlag:d}=t;switch(c){case r5:C(e,t,n,r);break;case r9:k(e,t,n,r);break;case r7:null==e&&w(t,n,r,s);break;case r8:j(e,t,n,r,i,l,s,o,a);break;default:1&d?N(e,t,n,r,i,l,s,o,a):6&d?U(e,t,n,r,i,l,s,o,a):64&d?c.process(e,t,n,r,i,l,s,o,a,ec):128&d&&c.process(e,t,n,r,i,l,s,o,a,ec)}null!=u&&i?nI(u,e&&e.ref,l,t||e,!t):null==u&&e&&null!=e.ref&&nI(e.ref,null,l,e,!0)},C=(e,t,n,r)=>{if(null==e)l(t.el=c(t.children),n,r);else{let n=t.el=e.el;t.children!==e.children&&h(n,t.children)}},k=(e,t,n,r)=>{null==e?l(t.el=d(t.children||""),n,r):t.el=e.el},w=(e,t,n,r)=>{[e.el,e.anchor]=y(e.children,t,n,r,e.el,e.anchor)},N=(e,t,n,r,i,l,s,o,a)=>{if("svg"===t.type?s="svg":"math"===t.type&&(s="mathml"),null==e)R(t,n,r,i,l,s,o,a);else{let n=e.el&&e.el._isVueCE?e.el:null;try{n&&n._beginPatch(),$(e,t,i,l,s,o,a)}finally{n&&n._endPatch()}}},R=(e,t,n,r,i,s,c,u)=>{let d,h,{props:f,shapeFlag:g,transition:m,dirs:y}=e;if(d=e.el=a(e.type,s,f&&f.is,f),8&g?p(d,e.children):16&g&&L(e.children,d,null,r,i,rJ(e,s),c,u),y&&t3(e,null,r,"created"),O(d,e,e.scopeId,c,r),f){for(let e in f)"value"===e||F(e)||o(d,e,null,f[e],s,r);"value"in f&&o(d,"value",null,f.value,s),(h=f.onVnodeBeforeMount)&&iC(h,r,e)}y&&t3(e,null,r,"beforeMount");let b=rX(i,m);b&&m.beforeEnter(d),l(d,t,n),((h=f&&f.onVnodeMounted)||b||y)&&rW(()=>{h&&iC(h,r,e),b&&m.enter(d),y&&t3(e,null,r,"mounted")},i)},O=(e,t,n,r,i)=>{if(n&&m(e,n),r)for(let t=0;t{for(let c=a;c{let a,c=t.el=e.el,{patchFlag:u,dynamicChildren:d,dirs:h}=t;u|=16&e.patchFlag;let f=e.props||b,g=t.props||b;if(n&&rG(n,!1),(a=g.onVnodeBeforeUpdate)&&iC(a,n,t,e),h&&t3(t,e,n,"beforeUpdate"),n&&rG(n,!0),(f.innerHTML&&null==g.innerHTML||f.textContent&&null==g.textContent)&&p(c,""),d?D(e.dynamicChildren,d,c,n,r,rJ(t,i),l):s||X(e,t,c,null,n,r,rJ(t,i),l,!1),u>0){if(16&u)V(c,f,g,n,i);else if(2&u&&f.class!==g.class&&o(c,"class",null,g.class,i),4&u&&o(c,"style",f.style,g.style,i),8&u){let e=t.dynamicProps;for(let t=0;t{a&&iC(a,n,t,e),h&&t3(t,e,n,"updated")},r)},D=(e,t,n,r,i,l,s)=>{for(let o=0;o{if(t!==n){if(t!==b)for(let l in t)F(l)||l in n||o(e,l,t[l],null,i,r);for(let l in n){if(F(l))continue;let s=n[l],a=t[l];s!==a&&"value"!==l&&o(e,l,a,s,i,r)}"value"in n&&o(e,"value",t.value,n.value,i)}},j=(e,t,n,r,i,s,o,a,u)=>{let d=t.el=e?e.el:c(""),h=t.anchor=e?e.anchor:c(""),{patchFlag:p,dynamicChildren:f,slotScopeIds:g}=t;g&&(a=a?a.concat(g):g),null==e?(l(d,n,r),l(h,n,r),L(t.children||[],n,h,i,s,o,a,u)):p>0&&64&p&&f&&e.dynamicChildren&&e.dynamicChildren.length===f.length?(D(e.dynamicChildren,f,n,i,s,o,a),(null!=t.key||i&&t===i.subTree)&&rQ(e,t,!0)):X(e,t,n,h,i,s,o,a,u)},U=(e,t,n,r,i,l,s,o,a)=>{t.slotScopeIds=o,null==e?512&t.shapeFlag?i.ctx.activate(t,n,r,s,a):q(t,n,r,i,l,s,a):W(e,t,a)},q=(e,t,n,r,i,l,s)=>{var o,a,c;let d,h,p,f=(o=e,a=r,c=i,d=o.type,h=(a?a.appContext:o.appContext)||ik,(p={uid:iT++,vnode:o,type:d,parent:a,appContext:h,root:null,next:null,subTree:null,effect:null,update:null,job:null,scope:new em(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:a?a.provides:Object.create(h.provides),ids:a?a.ids:["",0,0],accessCache:null,renderCache:[],components:null,directives:null,propsOptions:function e(t,n,r=!1){let i=r?rF:n.propsCache,l=i.get(t);if(l)return l;let s=t.props,o={},a=[],c=!1;if(!I(t)){let i=t=>{c=!0;let[r,i]=e(t,n,!0);T(o,r),i&&a.push(...i)};!r&&n.mixins.length&&n.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}if(!s&&!c)return M(t)&&i.set(t,_),_;if(E(s))for(let e=0;e{let r=e(t,n,!0);r&&(a=!0,T(o,r))};!r&&n.mixins.length&&n.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}return s||a?(E(s)?s.forEach(e=>o[e]=null):T(o,s),M(t)&&i.set(t,o),o):(M(t)&&i.set(t,null),null)}(d,h),emit:null,emitted:null,propsDefaults:b,inheritAttrs:d.inheritAttrs,ctx:b,data:b,props:b,attrs:b,slots:b,refs:b,setupState:b,setupContext:null,suspense:c,suspenseId:c?c.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null}).ctx={_:p},p.root=a?a.root:p,p.emit=rk.bind(null,p),o.ce&&o.ce(p),e.component=p);if(nq(e)&&(f.ctx.renderer=ec),function(e,t=!1,n=!1){t&&u(t);let{props:r,children:i}=e.vnode,l=iI(e);!function(e,t,n,r=!1){let i={},l=Object.create(rM);for(let n in e.propsDefaults=Object.create(null),rL(e,t,i,l),e.propsOptions[0])n in i||(i[n]=void 0);n?e.props=r?i:tc(i):e.type.props?e.props=i:e.props=l,e.attrs=l}(e,r,l,t);var s=n||t;let o=e.slots=Object.create(rM);if(32&e.vnode.shapeFlag){let e=i._;e?(rq(o,i,s),s&&J(o,"_",e,!0)):rU(i,o)}else i&&rH(e,i);l&&function(e,t){let n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,rs);let{setup:r}=n;if(r){eE();let n=e.setupContext=r.length>1?i$(e):null,i=iA(e),l=tF(r,e,0,[e.props,n]),s=P(l);if(eI(),i(),(s||e.sp)&&!nU(e)&&nN(e),s){if(l.then(iE,iE),t)return l.then(n=>{iO(e,n,t)}).catch(t=>{tV(t,e,0)});e.asyncDep=l}else iO(e,l,t)}else iP(e,t)}(e,t),t&&u(!1)}(f,!1,s),f.asyncDep){if(i&&i.registerDep(f,K,s),!e.el){let r=f.subTree=ig(r9);k(null,r,t,n),e.placeholder=r.el}}else K(f,e,t,n,i,l,s)},W=(e,t,n)=>{let r=t.component=e.component;if(function(e,t,n){let{props:r,children:i,component:l}=e,{props:s,children:o,patchFlag:a}=t,c=l.emitsOptions;if(t.dirs||t.transition)return!0;if(!n||!(a>=0))return(!!i||!!o)&&(!o||!o.$stable)||r!==s&&(r?!s||rI(r,s,c):!!s);if(1024&a)return!0;if(16&a)return r?rI(r,s,c):!!s;if(8&a){let e=t.dynamicProps;for(let t=0;t{e.scope.on();let a=e.effect=new ey(()=>{if(e.isMounted){let t,{next:n,bu:r,u:i,parent:a,vnode:u}=e;{let t=function e(t){let n=t.subTree.component;if(n)if(n.asyncDep&&!n.asyncResolved)return n;else return e(n)}(e);if(t){n&&(n.el=u.el,G(e,n,o)),t.asyncDep.then(()=>{rW(()=>{e.isUnmounted||c()},l)});return}}let d=n;rG(e,!1),n?(n.el=u.el,G(e,n,o)):n=u,r&&z(r),(t=n.props&&n.props.onVnodeBeforeUpdate)&&iC(t,a,n,u),rG(e,!0);let h=rN(e),p=e.subTree;e.subTree=h,x(p,h,f(p.el),es(p),e,l,s),n.el=h.el,null===d&&rO(e,h.el),i&&rW(i,l),(t=n.props&&n.props.onVnodeUpdated)&&rW(()=>iC(t,a,n,u),l)}else{let o,{el:a,props:c}=t,{bm:u,m:d,parent:h,root:p,type:f}=e,g=nU(t);if(rG(e,!1),u&&z(u),!g&&(o=c&&c.onVnodeBeforeMount)&&iC(o,h,t),rG(e,!0),a&&i){let t=()=>{e.subTree=rN(e),i(a,e.subTree,e,l,null)};g&&f.__asyncHydrate?f.__asyncHydrate(a,e,t):t()}else{p.ce&&p.ce._hasShadowRoot()&&p.ce._injectChildStyle(f);let i=e.subTree=rN(e);x(null,i,n,r,e,l,s),t.el=i.el}if(d&&rW(d,l),!g&&(o=c&&c.onVnodeMounted)){let e=t;rW(()=>iC(o,h,e),l)}(256&t.shapeFlag||h&&nU(h.vnode)&&256&h.vnode.shapeFlag)&&e.a&&rW(e.a,l),e.isMounted=!0,t=n=r=null}});e.scope.off();let c=e.update=a.run.bind(a),u=e.job=a.runIfDirty.bind(a);u.i=e,u.id=e.uid,a.scheduler=()=>tJ(u),rG(e,!0),c()},G=(e,t,n)=>{t.component=e;let r=e.vnode.props;e.vnode=t,e.next=null,function(e,t,n,r){let{props:i,attrs:l,vnode:{patchFlag:s}}=e,o=tm(i),[a]=e.propsOptions,c=!1;if((r||s>0)&&!(16&s)){if(8&s){let n=e.vnode.dynamicProps;for(let r=0;r{let{vnode:r,slots:i}=e,l=!0,s=b;if(32&r.shapeFlag){let e=t._;e?n&&1===e?l=!1:rq(i,t,n):(l=!t.$stable,rU(t,i)),s=t}else t&&(rH(e,t),s={default:1});if(l)for(let e in i)rV(e)||null!=s[e]||delete i[e]})(e,t.children,n),eE(),tQ(e),eI()},X=(e,t,n,r,i,l,s,o,a=!1)=>{let c=e&&e.children,u=e?e.shapeFlag:0,d=t.children,{patchFlag:h,shapeFlag:f}=t;if(h>0){if(128&h)return void Y(c,d,n,r,i,l,s,o,a);else if(256&h)return void Z(c,d,n,r,i,l,s,o,a)}8&f?(16&u&&el(c,i,l),d!==c&&p(n,d)):16&u?16&f?Y(c,d,n,r,i,l,s,o,a):el(c,i,l,!0):(8&u&&p(n,""),16&f&&L(d,n,r,i,l,s,o,a))},Z=(e,t,n,r,i,l,s,o,a)=>{let c;e=e||_,t=t||_;let u=e.length,d=t.length,h=Math.min(u,d);for(c=0;cd?el(e,i,l,!0,!1,h):L(t,n,r,i,l,s,o,a,h)},Y=(e,t,n,r,i,l,s,o,a)=>{let c=0,u=t.length,d=e.length-1,h=u-1;for(;c<=d&&c<=h;){let r=e[c],u=t[c]=a?i_(t[c]):ib(t[c]);if(iu(r,u))x(r,u,n,null,i,l,s,o,a);else break;c++}for(;c<=d&&c<=h;){let r=e[d],c=t[h]=a?i_(t[h]):ib(t[h]);if(iu(r,c))x(r,c,n,null,i,l,s,o,a);else break;d--,h--}if(c>d){if(c<=h){let e=h+1,d=eh)for(;c<=d;)et(e[c],i,l,!0),c++;else{let p,f=c,g=c,m=new Map;for(c=g;c<=h;c++){let e=t[c]=a?i_(t[c]):ib(t[c]);null!=e.key&&m.set(e.key,c)}let y=0,b=h-g+1,S=!1,C=0,k=Array(b);for(c=0;c=b){et(u,i,l,!0);continue}if(null!=u.key)r=m.get(u.key);else for(p=g;p<=h;p++)if(0===k[p-g]&&iu(u,t[p])){r=p;break}void 0===r?et(u,i,l,!0):(k[r-g]=c+1,r>=C?C=r:S=!0,x(u,t[r],n,null,i,l,s,o,a),y++)}let T=S?function(e){let t,n,r,i,l,s=e.slice(),o=[0],a=e.length;for(t=0;t>1]]0&&(s[t]=o[r-1]),o[r]=t)}}for(r=o.length,i=o[r-1];r-- >0;)o[r]=i,i=s[i];return o}(k):_;for(p=T.length-1,c=b-1;c>=0;c--){let e=g+c,d=t[e],h=t[e+1],f=e+1{let{el:o,type:a,transition:c,children:u,shapeFlag:d}=e;if(6&d)return void ee(e.component.subTree,t,n,r);if(128&d)return void e.suspense.move(t,n,r);if(64&d)return void a.move(e,t,n,ec);if(a===r8){l(o,t,n);for(let e=0;e{let i;for(;e&&e!==t;)i=g(e),l(e,n,r),e=i;l(t,n,r)})(e,t,n);if(2!==r&&1&d&&c)if(0===r)c.beforeEnter(o),l(o,t,n),rW(()=>c.enter(o),i);else{let{leave:r,delayLeave:i,afterLeave:a}=c,u=()=>{e.ctx.isUnmounted?s(o):l(o,t,n)},d=()=>{o._isLeaving&&o[nh](!0),r(o,()=>{u(),a&&a()})};i?i(o,u,d):d()}else l(o,t,n)},et=(e,t,n,r=!1,i=!1)=>{let l,{type:s,props:o,ref:a,children:c,dynamicChildren:u,shapeFlag:d,patchFlag:h,dirs:p,cacheIndex:f}=e;if(-2===h&&(i=!1),null!=a&&(eE(),nI(a,null,n,e,!0),eI()),null!=f&&(t.renderCache[f]=void 0),256&d)return void t.ctx.deactivate(e);let g=1&d&&p,m=!nU(e);if(m&&(l=o&&o.onVnodeBeforeUnmount)&&iC(l,t,e),6&d)ei(e.component,n,r);else{if(128&d)return void e.suspense.unmount(n,r);g&&t3(e,null,t,"beforeUnmount"),64&d?e.type.remove(e,t,n,ec,r):u&&!u.hasOnce&&(s!==r8||h>0&&64&h)?el(u,t,n,!1,!0):(s===r8&&384&h||!i&&16&d)&&el(c,t,n),r&&en(e)}(m&&(l=o&&o.onVnodeUnmounted)||g)&&rW(()=>{l&&iC(l,t,e),g&&t3(e,null,t,"unmounted")},n)},en=e=>{let{type:t,el:n,anchor:r,transition:i}=e;if(t===r8)return void er(n,r);if(t===r7)return void(({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=g(e),s(e),e=n;s(t)})(e);let l=()=>{s(n),i&&!i.persisted&&i.afterLeave&&i.afterLeave()};if(1&e.shapeFlag&&i&&!i.persisted){let{leave:t,delayLeave:r}=i,s=()=>t(n,l);r?r(e.el,l,s):s()}else l()},er=(e,t)=>{let n;for(;e!==t;)n=g(e),s(e),e=n;s(t)},ei=(e,t,n)=>{let{bum:r,scope:i,job:l,subTree:s,um:o,m:a,a:c}=e;rZ(a),rZ(c),r&&z(r),i.stop(),l&&(l.flags|=8,et(s,e,t,n)),o&&rW(o,t),rW(()=>{e.isUnmounted=!0},t)},el=(e,t,n,r=!1,i=!1,l=0)=>{for(let s=l;s{if(6&e.shapeFlag)return es(e.component.subTree);if(128&e.shapeFlag)return e.suspense.next();let t=g(e.anchor||e.el),n=t&&t[nn];return n?g(n):t},eo=!1,ea=(e,t,n)=>{let r;null==e?t._vnode&&(et(t._vnode,null,null,!0),r=t._vnode.component):x(t._vnode||null,e,t,null,null,null,n),t._vnode=e,eo||(eo=!0,tQ(r),tZ(),eo=!1)},ec={p:x,um:et,m:ee,r:en,mt:q,mc:L,pc:X,pbc:D,n:es,o:e};return t&&([r,i]=t(ec)),{render:ea,hydrate:r,createApp:(n=r,function(e,t=null){I(e)||(e=T({},e)),null==t||M(t)||(t=null);let r=r_(),i=new WeakSet,l=[],s=!1,o=r.app={_uid:rS++,_component:e,_props:t,_container:null,_context:r,_instance:null,version:iU,get config(){return r.config},set config(v){},use:(e,...t)=>(i.has(e)||(e&&I(e.install)?(i.add(e),e.install(o,...t)):I(e)&&(i.add(e),e(o,...t))),o),mixin:e=>(r.mixins.includes(e)||r.mixins.push(e),o),component:(e,t)=>t?(r.components[e]=t,o):r.components[e],directive:(e,t)=>t?(r.directives[e]=t,o):r.directives[e],mount(i,l,a){if(!s){let c=o._ceVNode||ig(e,t);return c.appContext=r,!0===a?a="svg":!1===a&&(a=void 0),l&&n?n(c,i):ea(c,i,a),s=!0,o._container=i,i.__vue_app__=o,iF(c.component)}},onUnmount(e){l.push(e)},unmount(){s&&(tD(l,o._instance,16),ea(null,o._container),delete o._container.__vue_app__)},provide:(e,t)=>(r.provides[e]=t,o),runWithContext(e){let t=rx;rx=o;try{return e()}finally{rx=t}}};return o})}}function rJ({type:e,props:t},n){return"svg"===n&&"foreignObject"===e||"mathml"===n&&"annotation-xml"===e&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function rG({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function rX(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function rQ(e,t,n=!1){let r=e.children,i=t.children;if(E(r)&&E(i))for(let e=0;ee.__isSuspense,r0=0;function r1(e,t){let n=e.props&&e.props[t];I(n)&&n()}function r2(e,t,n,r,i,l,s,o,a,c,u=!1){var d;let h,p,{p:f,m:g,um:m,n:y,o:{parentNode:b,remove:_}}=c,S=null!=(h=(d=e).props&&d.props.suspensible)&&!1!==h;S&&t&&t.pendingBranch&&(p=t.pendingId,t.deps++);let x=e.props?X(e.props.timeout):void 0,C=l,k={vnode:e,parent:t,parentComponent:n,namespace:s,container:r,hiddenContainer:i,deps:0,pendingId:r0++,timeout:"number"==typeof x?x:-1,activeBranch:null,pendingBranch:null,isInFallback:!u,isHydrating:u,isUnmounted:!1,effects:[],resolve(e=!1,n=!1){let{vnode:r,activeBranch:i,pendingBranch:s,pendingId:o,effects:a,parentComponent:c,container:u,isInFallback:d}=k,h=!1;k.isHydrating?k.isHydrating=!1:!e&&((h=i&&s.transition&&"out-in"===s.transition.mode)&&(i.transition.afterLeave=()=>{o===k.pendingId&&(g(s,u,l===C?y(i):l,0),tX(a),d&&r.ssFallback&&(r.ssFallback.el=null))}),i&&(b(i.el)===u&&(l=y(i)),m(i,c,k,!0),!h&&d&&r.ssFallback&&rW(()=>r.ssFallback.el=null,k)),h||g(s,u,l,0)),r4(k,s),k.pendingBranch=null,k.isInFallback=!1;let f=k.parent,_=!1;for(;f;){if(f.pendingBranch){f.effects.push(...a),_=!0;break}f=f.parent}_||h||tX(a),k.effects=[],S&&t&&t.pendingBranch&&p===t.pendingId&&(t.deps--,0!==t.deps||n||t.resolve()),r1(r,"onResolve")},fallback(e){if(!k.pendingBranch)return;let{vnode:t,activeBranch:n,parentComponent:r,container:i,namespace:l}=k;r1(t,"onFallback");let s=y(n),c=()=>{k.isInFallback&&(f(null,e,i,s,r,null,l,o,a),r4(k,e))},u=e.transition&&"out-in"===e.transition.mode;u&&(n.transition.afterLeave=c),k.isInFallback=!0,m(n,r,null,!0),u||c()},move(e,t,n){k.activeBranch&&g(k.activeBranch,e,t,n),k.container=e},next:()=>k.activeBranch&&y(k.activeBranch),registerDep(e,t,n){let r=!!k.pendingBranch;r&&k.deps++;let i=e.vnode.el;e.asyncDep.catch(t=>{tV(t,e,0)}).then(l=>{if(e.isUnmounted||k.isUnmounted||k.pendingId!==e.suspenseId)return;e.asyncResolved=!0;let{vnode:o}=e;iO(e,l,!1),i&&(o.el=i);let a=!i&&e.subTree.el;t(e,o,b(i||e.subTree.el),i?null:y(e.subTree),k,s,n),a&&(o.placeholder=null,_(a)),rO(e,o.el),r&&0==--k.deps&&k.resolve()})},unmount(e,t){k.isUnmounted=!0,k.activeBranch&&m(k.activeBranch,n,e,t),k.pendingBranch&&m(k.pendingBranch,n,e,t)}};return k}function r6(e){let t;if(I(e)){let n=il&&e._c;n&&(e._d=!1,ir()),e=e(),n&&(e._d=!0,t=it,ii())}return E(e)&&(e=function(e){let t;for(let n=0;nt!==e)),e}function r3(e,t){t&&t.pendingBranch?E(e)?t.effects.push(...e):t.effects.push(e):tX(e)}function r4(e,t){e.activeBranch=t;let{vnode:n,parentComponent:r}=e,i=t.el;for(;!i&&t.component;)i=(t=t.component.subTree).el;n.el=i,r&&r.subTree===n&&(r.vnode.el=i,rO(r,i))}let r8=Symbol.for("v-fgt"),r5=Symbol.for("v-txt"),r9=Symbol.for("v-cmt"),r7=Symbol.for("v-stc"),ie=[],it=null;function ir(e=!1){ie.push(it=e?null:[])}function ii(){ie.pop(),it=ie[ie.length-1]||null}let il=1;function is(e,t=!1){il+=e,e<0&&it&&t&&(it.hasOnce=!0)}function io(e){return e.dynamicChildren=il>0?it||_:null,ii(),il>0&&it&&it.push(e),e}function ia(e,t,n,r,i){return io(ig(e,t,n,r,i,!0))}function ic(e){return!!e&&!0===e.__v_isVNode}function iu(e,t){return e.type===t.type&&e.key===t.key}let id=({key:e})=>null!=e?e:null,ih=({ref:e,ref_key:t,ref_for:n})=>("number"==typeof e&&(e=""+e),null!=e?R(e)||t_(e)||I(e)?{i:t0,r:e,k:t,f:!!n}:e:null);function ip(e,t=null,n=null,r=0,i=null,l=+(e!==r8),s=!1,o=!1){let a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&id(t),ref:t&&ih(t),scopeId:t1,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:l,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:t0};return o?(iS(a,n),128&l&&e.normalize(a)):n&&(a.shapeFlag|=R(n)?8:16),il>0&&!s&&it&&(a.patchFlag>0||6&l)&&32!==a.patchFlag&&it.push(a),a}let ig=function(e,t=null,n=null,r=0,i=null,l=!1){var s;if(e&&e!==re||(e=r9),ic(e)){let r=iv(e,t,!0);return n&&iS(r,n),il>0&&!l&&it&&(6&r.shapeFlag?it[it.indexOf(e)]=r:it.push(r)),r.patchFlag=-2,r}if(I(s=e)&&"__vccOpts"in s&&(e=e.__vccOpts),t){let{class:e,style:n}=t=im(t);e&&!R(e)&&(t.class=ei(e)),M(n)&&(tg(n)&&!E(n)&&(n=T({},n)),t.style=Y(n))}let o=R(e)?1:rY(e)?128:e.__isTeleport?64:M(e)?4:2*!!I(e);return ip(e,t,n,r,i,o,l,!0)};function im(e){return e?tg(e)||rP(e)?T({},e):e:null}function iv(e,t,n=!1,r=!1){let{props:i,ref:l,patchFlag:s,children:o,transition:a}=e,c=t?ix(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&id(c),ref:t&&t.ref?n&&l?E(l)?l.concat(ih(t)):[l,ih(t)]:ih(t):l,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:o,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==r8?-1===s?16:16|s:s,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:a,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&iv(e.ssContent),ssFallback:e.ssFallback&&iv(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return a&&r&&nk(u,a.clone(u)),u}function iy(e=" ",t=0){return ig(r5,null,e,t)}function ib(e){return null==e||"boolean"==typeof e?ig(r9):E(e)?ig(r8,null,e.slice()):ic(e)?i_(e):ig(r5,null,String(e))}function i_(e){return null===e.el&&-1!==e.patchFlag||e.memo?e:iv(e)}function iS(e,t){let n=0,{shapeFlag:r}=e;if(null==t)t=null;else if(E(t))n=16;else if("object"==typeof t)if(65&r){let n=t.default;n&&(n._c&&(n._d=!1),iS(e,n()),n._c&&(n._d=!0));return}else{n=32;let r=t._;r||rP(t)?3===r&&t0&&(1===t0.slots._?t._=1:(t._=2,e.patchFlag|=1024)):t._ctx=t0}else I(t)?(t={default:t,_ctx:t0},n=32):(t=String(t),64&r?(n=16,t=[iy(t)]):n=8);e.children=t,e.shapeFlag|=n}function ix(...e){let t={};for(let n=0;niw||t0;c=e=>{iw=e},u=e=>{iR=e};let iA=e=>{let t=iw;return c(e),e.scope.on(),()=>{e.scope.off(),c(t)}},iE=()=>{iw&&iw.scope.off(),c(null)};function iI(e){return 4&e.vnode.shapeFlag}let iR=!1;function iO(e,t,n){I(t)?e.render=t:M(t)&&(e.setupState=tN(t)),iP(e,n)}function iM(e){d=e,h=e=>{e.render._rc&&(e.withProxy=new Proxy(e.ctx,ro))}}function iP(e,t,n){let r=e.type;if(!e.render){if(!t&&d&&!r.render){let t=r.template||rh(e).template;if(t){let{isCustomElement:n,compilerOptions:i}=e.appContext.config,{delimiters:l,compilerOptions:s}=r,o=T(T({isCustomElement:n,delimiters:l},i),s);r.render=d(t,o)}}e.render=r.render||S,h&&h(e)}{let t=iA(e);eE();try{!function(e){let t=rh(e),n=e.proxy,r=e.ctx;ru=!1,t.beforeCreate&&rd(t.beforeCreate,e,"bc");let{data:i,computed:l,methods:s,watch:o,provide:a,inject:c,created:u,beforeMount:d,mounted:h,beforeUpdate:p,updated:f,activated:g,deactivated:m,beforeUnmount:y,unmounted:b,render:_,renderTracked:x,renderTriggered:C,errorCaptured:k,serverPrefetch:T,expose:w,inheritAttrs:N,components:A,directives:O}=t;if(c&&function(e,t){for(let n in E(e)&&(e=rm(e)),e){let r,i=e[n];t_(r=M(i)?"default"in i?t8(i.from||n,i.default,!0):t8(i.from||n):t8(i))?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>r.value,set:e=>r.value=e}):t[n]=r}}(c,r),s)for(let e in s){let t=s[e];I(t)&&(r[e]=t.bind(n))}if(i){let t=i.call(n,n);M(t)&&(e.data=ta(t))}if(ru=!0,l)for(let e in l){let t=l[e],i=I(t)?t.bind(n,n):I(t.get)?t.get.bind(n,n):S,s=iV({get:i,set:!I(t)&&I(t.set)?t.set.bind(n):S});Object.defineProperty(r,e,{enumerable:!0,configurable:!0,get:()=>s.value,set:e=>s.value=e})}if(o)for(let e in o)!function e(t,n,r,i){let l=i.includes(".")?nt(r,i):()=>r[i];if(R(t)){let e=n[t];I(e)&&t7(l,e,void 0)}else if(I(t))t7(l,t.bind(r),void 0);else if(M(t))if(E(t))t.forEach(t=>e(t,n,r,i));else{let e=I(t.handler)?t.handler.bind(r):n[t.handler];I(e)&&t7(l,e,t)}}(o[e],r,n,e);if(a){let e=I(a)?a.call(n):a;Reflect.ownKeys(e).forEach(t=>{t4(t,e[t])})}function P(e,t){E(t)?t.forEach(t=>e(t.bind(n))):t&&e(t.bind(n))}if(u&&rd(u,e,"c"),P(nY,d),P(n0,h),P(n1,p),P(n2,f),P(nK,g),P(nz,m),P(n9,k),P(n5,x),P(n8,C),P(n6,y),P(n3,b),P(n4,T),E(w))if(w.length){let t=e.exposed||(e.exposed={});w.forEach(e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t,enumerable:!0})})}else e.exposed||(e.exposed={});_&&e.render===S&&(e.render=_),null!=N&&(e.inheritAttrs=N),A&&(e.components=A),O&&(e.directives=O)}(e)}finally{eI(),t()}}}let iL={get:(e,t)=>(eV(e,"get",""),e[t])};function i$(e){return{attrs:new Proxy(e.attrs,iL),slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function iF(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(tN(tv(e.exposed)),{get:(t,n)=>n in t?t[n]:n in ri?ri[n](e):void 0,has:(e,t)=>t in e||t in ri})):e.proxy}function iD(e,t=!0){return I(e)?e.displayName||e.name:e.name||t&&e.__name}let iV=(e,t)=>(function(e,t=!1){let n,r;return I(e)?n=e:(n=e.get,r=e.set),new tO(n,r,t)})(e,iR);function ij(e,t,n){try{is(-1);let r=arguments.length;if(2!==r)return r>3?n=Array.prototype.slice.call(arguments,2):3===r&&ic(n)&&(n=[n]),ig(e,t,n);if(!M(t)||E(t))return ig(e,null,t);if(ic(t))return ig(e,null,[t]);return ig(e,t)}finally{is(1)}}function iB(e,t){let n=e.memo;if(n.length!=t.length)return!1;for(let e=0;e0&&it&&it.push(e),!0}let iU="3.5.29",iH="u">typeof window&&window.trustedTypes;if(iH)try{m=iH.createPolicy("vue",{createHTML:e=>e})}catch(e){}let iq=m?e=>m.createHTML(e):e=>e,iW="u">typeof document?document:null,iK=iW&&iW.createElement("template"),iz={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i="svg"===t?iW.createElementNS("http://www.w3.org/2000/svg",e):"mathml"===t?iW.createElementNS("http://www.w3.org/1998/Math/MathML",e):n?iW.createElement(e,{is:n}):iW.createElement(e);return"select"===e&&r&&null!=r.multiple&&i.setAttribute("multiple",r.multiple),i},createText:e=>iW.createTextNode(e),createComment:e=>iW.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>iW.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,r,i,l){let s=n?n.previousSibling:t.lastChild;if(i&&(i===l||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),i!==l&&(i=i.nextSibling););else{iK.innerHTML=iq("svg"===r?``:"mathml"===r?``:e);let i=iK.content;if("svg"===r||"mathml"===r){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[s?s.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},iJ="transition",iG="animation",iX=Symbol("_vtc"),iQ={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},iZ=T({},nm,iQ),iY=((t=(e,{slots:t})=>ij(nb,i2(e),t)).displayName="Transition",t.props=iZ,t),i0=(e,t=[])=>{E(e)?e.forEach(e=>e(...t)):e&&e(...t)},i1=e=>!!e&&(E(e)?e.some(e=>e.length>1):e.length>1);function i2(e){let t={};for(let n in e)n in iQ||(t[n]=e[n]);if(!1===e.css)return t;let{name:n="v",type:r,duration:i,enterFromClass:l=`${n}-enter-from`,enterActiveClass:s=`${n}-enter-active`,enterToClass:o=`${n}-enter-to`,appearFromClass:a=l,appearActiveClass:c=s,appearToClass:u=o,leaveFromClass:d=`${n}-leave-from`,leaveActiveClass:h=`${n}-leave-active`,leaveToClass:p=`${n}-leave-to`}=e,f=function(e){if(null==e)return null;{if(M(e))return[function(e){return X(e)}(e.enter),function(e){return X(e)}(e.leave)];let t=function(e){return X(e)}(e);return[t,t]}}(i),g=f&&f[0],m=f&&f[1],{onBeforeEnter:y,onEnter:b,onEnterCancelled:_,onLeave:S,onLeaveCancelled:x,onBeforeAppear:C=y,onAppear:k=b,onAppearCancelled:w=_}=t,N=(e,t,n,r)=>{e._enterCancelled=r,i3(e,t?u:o),i3(e,t?c:s),n&&n()},A=(e,t)=>{e._isLeaving=!1,i3(e,d),i3(e,p),i3(e,h),t&&t()},E=e=>(t,n)=>{let i=e?k:b,s=()=>N(t,e,n);i0(i,[t,s]),i4(()=>{i3(t,e?a:l),i6(t,e?u:o),i1(i)||i5(t,r,g,s)})};return T(t,{onBeforeEnter(e){i0(y,[e]),i6(e,l),i6(e,s)},onBeforeAppear(e){i0(C,[e]),i6(e,a),i6(e,c)},onEnter:E(!1),onAppear:E(!0),onLeave(e,t){e._isLeaving=!0;let n=()=>A(e,t);i6(e,d),e._enterCancelled?(i6(e,h),lt(e)):(lt(e),i6(e,h)),i4(()=>{e._isLeaving&&(i3(e,d),i6(e,p),i1(S)||i5(e,r,m,n))}),i0(S,[e,n])},onEnterCancelled(e){N(e,!1,void 0,!0),i0(_,[e])},onAppearCancelled(e){N(e,!0,void 0,!0),i0(w,[e])},onLeaveCancelled(e){A(e),i0(x,[e])}})}function i6(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.add(t)),(e[iX]||(e[iX]=new Set)).add(t)}function i3(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.remove(t));let n=e[iX];n&&(n.delete(t),n.size||(e[iX]=void 0))}function i4(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let i8=0;function i5(e,t,n,r){let i=e._endId=++i8,l=()=>{i===e._endId&&r()};if(null!=n)return setTimeout(l,n);let{type:s,timeout:o,propCount:a}=i9(e,t);if(!s)return r();let c=s+"end",u=0,d=()=>{e.removeEventListener(c,h),l()},h=t=>{t.target===e&&++u>=a&&d()};setTimeout(()=>{u(n[e]||"").split(", "),i=r(`${iJ}Delay`),l=r(`${iJ}Duration`),s=i7(i,l),o=r(`${iG}Delay`),a=r(`${iG}Duration`),c=i7(o,a),u=null,d=0,h=0;t===iJ?s>0&&(u=iJ,d=s,h=l.length):t===iG?c>0&&(u=iG,d=c,h=a.length):h=(u=(d=Math.max(s,c))>0?s>c?iJ:iG:null)?u===iJ?l.length:a.length:0;let p=u===iJ&&/\b(?:transform|all)(?:,|$)/.test(r(`${iJ}Property`).toString());return{type:u,timeout:d,propCount:h,hasTransform:p}}function i7(e,t){for(;e.lengthle(t)+le(e[n])))}function le(e){return"auto"===e?0:1e3*Number(e.slice(0,-1).replace(",","."))}function lt(e){return(e?e.ownerDocument:document).body.offsetHeight}let ln=Symbol("_vod"),lr=Symbol("_vsh");function li(e,t){e.style.display=t?e[ln]:"none",e[lr]=!t}let ll=Symbol("");function ls(e,t){if(1===e.nodeType){let r=e.style,i="";for(let e in t){var n;let l=null==(n=t[e])?"initial":"string"==typeof n?""===n?" ":n:String(n);r.setProperty(`--${e}`,l),i+=`--${e}: ${l};`}r[ll]=i}}let lo=/(?:^|;)\s*display\s*:/,la=/\s*!important$/;function lc(e,t,n){if(E(n))n.forEach(n=>lc(e,t,n));else if(null==n&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{let r=function(e,t){let n=ld[t];if(n)return n;let r=B(t);if("filter"!==r&&r in e)return ld[t]=r;r=q(r);for(let n=0;n111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&e.charCodeAt(2)>96&&123>e.charCodeAt(2),lS=(e,t,n,r,i,l)=>{let s="svg"===i;if("class"===t){var o;let t;o=r,(t=e[iX])&&(o=(o?[o,...t]:[...t]).join(" ")),null==o?e.removeAttribute("class"):s?e.setAttribute("class",o):e.className=o}else"style"===t?function(e,t,n){let r=e.style,i=R(n),l=!1;if(n&&!i){if(t)if(R(t))for(let e of t.split(";")){let t=e.slice(0,e.indexOf(":")).trim();null==n[t]&&lc(r,t,"")}else for(let e in t)null==n[e]&&lc(r,e,"");for(let e in n)"display"===e&&(l=!0),lc(r,e,n[e])}else if(i){if(t!==n){let e=r[ll];e&&(n+=";"+e),r.cssText=n,l=lo.test(n)}}else t&&e.removeAttribute("style");ln in e&&(e[ln]=l?r.display:"",e[lr]&&(r.display="none"))}(e,n,r):C(t)?k(t)||function(e,t,n,r=null){let i=e[lm]||(e[lm]={}),l=i[t];if(n&&l)l.value=n;else{let[a,c]=function(e){let t;if(lv.test(e)){let n;for(t={};n=e.match(lv);)e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}return[":"===e[2]?e.slice(3):H(e.slice(2)),t]}(t);if(n){var s,o;let l;lg(e,a,i[t]=(s=n,o=r,(l=e=>{if(e._vts){if(e._vts<=l.attached)return}else e._vts=Date.now();tD(function(e,t){if(!E(t))return t;{let n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(e=>t=>!t._stopped&&e&&e(t))}}(e,l.value),o,5,[e])}).value=s,l.attached=ly||(lb.then(()=>ly=0),ly=Date.now()),l),c)}else l&&(e.removeEventListener(a,l,c),i[t]=void 0)}}(e,t,r,l):("."===t[0]?(t=t.slice(1),0):"^"===t[0]?(t=t.slice(1),1):!function(e,t,n,r){if(r)return!!("innerHTML"===t||"textContent"===t||t in e&&l_(t)&&I(n));if("spellcheck"===t||"draggable"===t||"translate"===t||"autocorrect"===t||"sandbox"===t&&"IFRAME"===e.tagName||"form"===t||"list"===t&&"INPUT"===e.tagName||"type"===t&&"TEXTAREA"===e.tagName)return!1;if("width"===t||"height"===t){let t=e.tagName;if("IMG"===t||"VIDEO"===t||"CANVAS"===t||"SOURCE"===t)return!1}return!(l_(t)&&R(n))&&t in e}(e,t,r,s))?e._isVueCE&&(/[A-Z]/.test(t)||!R(r))?lf(e,B(t),r,l,t):("true-value"===t?e._trueValue=r:"false-value"===t&&(e._falseValue=r),lp(e,t,r,s)):(lf(e,t,r),e.tagName.includes("-")||"value"!==t&&"checked"!==t&&"selected"!==t||lp(e,t,r,s,l,"value"!==t))},lx={};function lC(e,t,n){let r,i=nw(e,t);"[object Object]"===(r=i,L.call(r))&&(i=T({},i,t));class l extends lT{constructor(e){super(i,e,n)}}return l.def=i,l}let lk="u">typeof HTMLElement?HTMLElement:class{};class lT extends lk{constructor(e,t={},n=l6){super(),this._def=e,this._props=t,this._createApp=n,this._isVueCE=!0,this._instance=null,this._app=null,this._nonce=this._def.nonce,this._connected=!1,this._resolved=!1,this._patching=!1,this._dirty=!1,this._numberProps=null,this._styleChildren=new WeakSet,this._ob=null,this.shadowRoot&&n!==l6?this._root=this.shadowRoot:!1!==e.shadowRoot?(this.attachShadow(T({},e.shadowRootOptions,{mode:"open"})),this._root=this.shadowRoot):this._root=this}connectedCallback(){if(!this.isConnected)return;this.shadowRoot||this._resolved||this._parseSlots(),this._connected=!0;let e=this;for(;e=e&&(e.parentNode||e.host);)if(e instanceof lT){this._parent=e;break}this._instance||(this._resolved?this._mount(this._def):e&&e._pendingResolve?this._pendingResolve=e._pendingResolve.then(()=>{this._pendingResolve=void 0,this._resolveDef()}):this._resolveDef())}_setParent(e=this._parent){e&&(this._instance.parent=e._instance,this._inheritParentContext(e))}_inheritParentContext(e=this._parent){e&&this._app&&Object.setPrototypeOf(this._app._context.provides,e._instance.provides)}disconnectedCallback(){this._connected=!1,tz(()=>{!this._connected&&(this._ob&&(this._ob.disconnect(),this._ob=null),this._app&&this._app.unmount(),this._instance&&(this._instance.ce=void 0),this._app=this._instance=null,this._teleportTargets&&(this._teleportTargets.clear(),this._teleportTargets=void 0))})}_processMutations(e){for(let t of e)this._setAttr(t.attributeName)}_resolveDef(){if(this._pendingResolve)return;for(let e=0;e{let n;this._resolved=!0,this._pendingResolve=void 0;let{props:r,styles:i}=e;if(r&&!E(r))for(let e in r){let t=r[e];(t===Number||t&&t.type===Number)&&(e in this._props&&(this._props[e]=X(this._props[e])),(n||(n=Object.create(null)))[B(e)]=!0)}this._numberProps=n,this._resolveProps(e),this.shadowRoot&&this._applyStyles(i),this._mount(e)},t=this._def.__asyncLoader;t?this._pendingResolve=t().then(t=>{t.configureApp=this._def.configureApp,e(this._def=t,!0)}):e(this._def)}_mount(e){this._app=this._createApp(e),this._inheritParentContext(),e.configureApp&&e.configureApp(this._app),this._app._ceVNode=this._createVNode(),this._app.mount(this._root);let t=this._instance&&this._instance.exposed;if(t)for(let e in t)A(this,e)||Object.defineProperty(this,e,{get:()=>tT(t[e])})}_resolveProps(e){let{props:t}=e,n=E(t)?t:Object.keys(t||{});for(let e of Object.keys(this))"_"!==e[0]&&n.includes(e)&&this._setProp(e,this[e]);for(let e of n.map(B))Object.defineProperty(this,e,{get(){return this._getProp(e)},set(t){this._setProp(e,t,!0,!this._patching)}})}_setAttr(e){if(e.startsWith("data-v-"))return;let t=this.hasAttribute(e),n=t?this.getAttribute(e):lx,r=B(e);t&&this._numberProps&&this._numberProps[r]&&(n=X(n)),this._setProp(r,n,!1,!0)}_getProp(e){return this._props[e]}_setProp(e,t,n=!0,r=!1){if(t!==this._props[e]&&(this._dirty=!0,t===lx?delete this._props[e]:(this._props[e]=t,"key"===e&&this._app&&(this._app._ceVNode.key=t)),r&&this._instance&&this._update(),n)){let n=this._ob;n&&(this._processMutations(n.takeRecords()),n.disconnect()),!0===t?this.setAttribute(H(e),""):"string"==typeof t||"number"==typeof t?this.setAttribute(H(e),t+""):t||this.removeAttribute(H(e)),n&&n.observe(this,{attributes:!0})}}_update(){let e=this._createVNode();this._app&&(e.appContext=this._app._context),l2(e,this._root)}_createVNode(){let e={};this.shadowRoot||(e.onVnodeMounted=e.onVnodeUpdated=this._renderSlots.bind(this));let t=ig(this._def,T(e,this._props));return this._instance||(t.ce=e=>{this._instance=e,e.ce=this,e.isCE=!0;let t=(e,t)=>{let n;this.dispatchEvent(new CustomEvent(e,"[object Object]"===(n=t[0],L.call(n))?T({detail:t},t[0]):{detail:t}))};e.emit=(e,...n)=>{t(e,n),H(e)!==e&&t(H(e),n)},this._setParent()}),t}_applyStyles(e,t){if(!e)return;if(t){if(t===this._def||this._styleChildren.has(t))return;this._styleChildren.add(t)}let n=this._nonce;for(let t=e.length-1;t>=0;t--){let r=document.createElement("style");n&&r.setAttribute("nonce",n),r.textContent=e[t],this.shadowRoot.prepend(r)}}_parseSlots(){let e,t=this._slots={};for(;e=this.firstChild;){let n=1===e.nodeType&&e.getAttribute("slot")||"default";(t[n]||(t[n]=[])).push(e),this.removeChild(e)}}_renderSlots(){let e=this._getSlots(),t=this._instance.type.__scopeId;for(let n=0;n{if(!n.length)return;let t=e.moveClass||`${e.name||"v"}-move`;if(!function(e,t,n){let r=e.cloneNode(),i=e[iX];i&&i.forEach(e=>{e.split(/\s+/).forEach(e=>e&&r.classList.remove(e))}),n.split(/\s+/).forEach(e=>e&&r.classList.add(e)),r.style.display="none";let l=1===t.nodeType?t:t.parentNode;l.appendChild(r);let{hasTransform:s}=i9(r);return l.removeChild(r),s}(n[0].el,i.vnode.el,t)){n=[];return}n.forEach(lO),n.forEach(lM);let r=n.filter(lP);lt(i.vnode.el),r.forEach(e=>{let n=e.el,r=n.style;i6(n,t),r.transform=r.webkitTransform=r.transitionDuration="";let i=n[lE]=e=>{(!e||e.target===n)&&(!e||e.propertyName.endsWith("transform"))&&(n.removeEventListener("transitionend",i),n[lE]=null,i3(n,t))};n.addEventListener("transitionend",i)}),n=[]}),()=>{let s=tm(e),o=i2(s),a=s.tag||r8;if(n=[],r)for(let e=0;eMath.abs(s-1)&&(s=1),.01>Math.abs(o-1)&&(o=1),n.transform=n.webkitTransform=`translate(${r/s}px,${i/o}px)`,n.transitionDuration="0s",e}}function lL(e){let t=e.getBoundingClientRect();return{left:t.left,top:t.top}}let l$=e=>{let t=e.props["onUpdate:modelValue"]||!1;return E(t)?e=>z(t,e):t};function lF(e){e.target.composing=!0}function lD(e){let t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}let lV=Symbol("_assign");function lj(e,t,n){return t&&(e=e.trim()),n&&(e=G(e)),e}let lB={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e[lV]=l$(i);let l=r||i.props&&"number"===i.props.type;lg(e,t?"change":"input",t=>{t.target.composing||e[lV](lj(e.value,n,l))}),(n||l)&&lg(e,"change",()=>{e.value=lj(e.value,n,l)}),t||(lg(e,"compositionstart",lF),lg(e,"compositionend",lD),lg(e,"change",lD))},mounted(e,{value:t}){e.value=null==t?"":t},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:i,number:l}},s){if(e[lV]=l$(s),e.composing)return;let o=(l||"number"===e.type)&&!/^0\d/.test(e.value)?G(e.value):e.value,a=null==t?"":t;if(o!==a){if(document.activeElement===e&&"range"!==e.type&&(r&&t===n||i&&e.value.trim()===a))return;e.value=a}}},lU={deep:!0,created(e,t,n){e[lV]=l$(n),lg(e,"change",()=>{let t=e._modelValue,n=lz(e),r=e.checked,i=e[lV];if(E(t)){let e=ed(t,n),l=-1!==e;if(r&&!l)i(t.concat(n));else if(!r&&l){let n=[...t];n.splice(e,1),i(n)}}else{let l;if("[object Set]"===(l=t,L.call(l))){let e=new Set(t);r?e.add(n):e.delete(n),i(e)}else i(lJ(e,r))}})},mounted:lH,beforeUpdate(e,t,n){e[lV]=l$(n),lH(e,t,n)}};function lH(e,{value:t,oldValue:n},r){let i;if(e._modelValue=t,E(t))i=ed(t,r.props.value)>-1;else{let l;if("[object Set]"===(l=t,L.call(l)))i=t.has(r.props.value);else{if(t===n)return;i=eu(t,lJ(e,!0))}}e.checked!==i&&(e.checked=i)}let lq={created(e,{value:t},n){e.checked=eu(t,n.props.value),e[lV]=l$(n),lg(e,"change",()=>{e[lV](lz(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e[lV]=l$(r),t!==n&&(e.checked=eu(t,r.props.value))}},lW={deep:!0,created(e,{value:t,modifiers:{number:n}},r){let i,l="[object Set]"===(i=t,L.call(i));lg(e,"change",()=>{let t=Array.prototype.filter.call(e.options,e=>e.selected).map(e=>n?G(lz(e)):lz(e));e[lV](e.multiple?l?new Set(t):t:t[0]),e._assigning=!0,tz(()=>{e._assigning=!1})}),e[lV]=l$(r)},mounted(e,{value:t}){lK(e,t)},beforeUpdate(e,t,n){e[lV]=l$(n)},updated(e,{value:t}){e._assigning||lK(e,t)}};function lK(e,t){let n,r=e.multiple,i=E(t);if(!r||i||"[object Set]"===(n=t,L.call(n))){for(let n=0,l=e.options.length;nString(e)===String(s)):l.selected=ed(t,s)>-1}else l.selected=t.has(s);else if(eu(lz(l),t)){e.selectedIndex!==n&&(e.selectedIndex=n);return}}r||-1===e.selectedIndex||(e.selectedIndex=-1)}}function lz(e){return"_value"in e?e._value:e.value}function lJ(e,t){let n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}function lG(e,t,n,r,i){let l=function(e,t){switch(e){case"SELECT":return lW;case"TEXTAREA":return lB;default:switch(t){case"checkbox":return lU;case"radio":return lq;default:return lB}}}(e.tagName,n.props&&n.props.type)[i];l&&l(e,t,n,r)}let lX=["ctrl","shift","alt","meta"],lQ={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&0!==e.button,middle:e=>"button"in e&&1!==e.button,right:e=>"button"in e&&2!==e.button,exact:(e,t)=>lX.some(n=>e[`${n}Key`]&&!t.includes(n))},lZ={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},lY=T({patchProp:lS},iz),l0=!1;function l1(){return p=l0?p:rK(lY),l0=!0,p}let l2=(...e)=>{(p||(p=rz(lY))).render(...e)},l6=(...e)=>{let t=(p||(p=rz(lY))).createApp(...e),{mount:n}=t;return t.mount=e=>{let r=l8(e);if(!r)return;let i=t._component;I(i)||i.render||i.template||(i.template=r.innerHTML),1===r.nodeType&&(r.textContent="");let l=n(r,!1,l4(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),l},t},l3=(...e)=>{let t=l1().createApp(...e),{mount:n}=t;return t.mount=e=>{let t=l8(e);if(t)return n(t,!0,l4(t))},t};function l4(e){return e instanceof SVGElement?"svg":"function"==typeof MathMLElement&&e instanceof MathMLElement?"mathml":void 0}function l8(e){return R(e)?document.querySelector(e):e}let l5=Symbol(""),l9=Symbol(""),l7=Symbol(""),se=Symbol(""),st=Symbol(""),sn=Symbol(""),sr=Symbol(""),si=Symbol(""),sl=Symbol(""),ss=Symbol(""),so=Symbol(""),sa=Symbol(""),sc=Symbol(""),su=Symbol(""),sd=Symbol(""),sh=Symbol(""),sp=Symbol(""),sf=Symbol(""),sg=Symbol(""),sm=Symbol(""),sv=Symbol(""),sy=Symbol(""),sb=Symbol(""),s_=Symbol(""),sS=Symbol(""),sx=Symbol(""),sC=Symbol(""),sk=Symbol(""),sT=Symbol(""),sw=Symbol(""),sN=Symbol(""),sA=Symbol(""),sE=Symbol(""),sI=Symbol(""),sR=Symbol(""),sO=Symbol(""),sM=Symbol(""),sP=Symbol(""),sL=Symbol(""),s$={[l5]:"Fragment",[l9]:"Teleport",[l7]:"Suspense",[se]:"KeepAlive",[st]:"BaseTransition",[sn]:"openBlock",[sr]:"createBlock",[si]:"createElementBlock",[sl]:"createVNode",[ss]:"createElementVNode",[so]:"createCommentVNode",[sa]:"createTextVNode",[sc]:"createStaticVNode",[su]:"resolveComponent",[sd]:"resolveDynamicComponent",[sh]:"resolveDirective",[sp]:"resolveFilter",[sf]:"withDirectives",[sg]:"renderList",[sm]:"renderSlot",[sv]:"createSlots",[sy]:"toDisplayString",[sb]:"mergeProps",[s_]:"normalizeClass",[sS]:"normalizeStyle",[sx]:"normalizeProps",[sC]:"guardReactiveProps",[sk]:"toHandlers",[sT]:"camelize",[sw]:"capitalize",[sN]:"toHandlerKey",[sA]:"setBlockTracking",[sE]:"pushScopeId",[sI]:"popScopeId",[sR]:"withCtx",[sO]:"unref",[sM]:"isRef",[sP]:"withMemo",[sL]:"isMemoSame"},sF={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function sD(e,t,n,r,i,l,s,o=!1,a=!1,c=!1,u=sF){var d,h,p,f;return e&&(o?(e.helper(sn),e.helper((d=e.inSSR,h=c,d||h?sr:si))):e.helper((p=e.inSSR,f=c,p||f?sl:ss)),s&&e.helper(sf)),{type:13,tag:t,props:n,children:r,patchFlag:i,dynamicProps:l,directives:s,isBlock:o,disableTracking:a,isComponent:c,loc:u}}function sV(e,t=sF){return{type:17,loc:t,elements:e}}function sj(e,t=sF){return{type:15,loc:t,properties:e}}function sB(e,t){return{type:16,loc:sF,key:R(e)?sU(e,!0):e,value:t}}function sU(e,t=!1,n=sF,r=0){return{type:4,loc:n,content:e,isStatic:t,constType:t?3:r}}function sH(e,t=sF){return{type:8,loc:t,children:e}}function sq(e,t=[],n=sF){return{type:14,loc:n,callee:e,arguments:t}}function sW(e,t,n=!1,r=!1,i=sF){return{type:18,params:e,returns:t,newline:n,isSlot:r,loc:i}}function sK(e,t,n,r=!0){return{type:19,test:e,consequent:t,alternate:n,newline:r,loc:sF}}function sz(e,{helper:t,removeHelper:n,inSSR:r}){if(!e.isBlock){var i,l;e.isBlock=!0,n((i=e.isComponent,r||i?sl:ss)),t(sn),t((l=e.isComponent,r||l?sr:si))}}let sJ=new Uint8Array([123,123]),sG=new Uint8Array([125,125]);function sX(e){return e>=97&&e<=122||e>=65&&e<=90}function sQ(e){return 32===e||10===e||9===e||12===e||13===e}function sZ(e){return 47===e||62===e||sQ(e)}function sY(e){let t=new Uint8Array(e.length);for(let n=0;n4===e.type&&e.isStatic;function s4(e){switch(e){case"Teleport":case"teleport":return l9;case"Suspense":case"suspense":return l7;case"KeepAlive":case"keep-alive":return se;case"BaseTransition":case"base-transition":return st}}let s8=/^$|^\d|[^\$\w\xA0-\uFFFF]/,s5=/[A-Za-z_$\xA0-\uFFFF]/,s9=/[\.\?\w$\xA0-\uFFFF]/,s7=/\s+[.[]\s*|\s*[.[]\s+/g,oe=e=>4===e.type?e.content:e.loc.source,ot=e=>{let t=oe(e).trim().replace(s7,e=>e.trim()),n=0,r=[],i=0,l=0,s=null;for(let e=0;e|^\s*(?:async\s+)?function(?:\s+[\w$]+)?\s*\(/;function or(e,t,n=!1){for(let r=0;r4===e.key.type&&e.key.content===r)}return n}function of(e,t){return`_${t}_${e.replace(/[^\w]/g,(t,n)=>"-"===t?"_":e.charCodeAt(n).toString())}`}let og=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;function om(e){for(let t=0;t0,isVoidTag:x,isPreTag:x,isIgnoreNewlineTag:x,isCustomElement:x,onError:s1,onWarn:s2,comments:!1,prefixIdentifiers:!1},o_=ob,oS=null,ox="",oC=null,ok=null,oT="",ow=-1,oN=-1,oA=0,oE=!1,oI=null,oR=[],oO=new class{constructor(e,t){this.stack=e,this.cbs=t,this.state=1,this.buffer="",this.sectionStart=0,this.index=0,this.entityStart=0,this.baseState=1,this.inRCDATA=!1,this.inXML=!1,this.inVPre=!1,this.newlines=[],this.mode=0,this.delimiterOpen=sJ,this.delimiterClose=sG,this.delimiterIndex=-1,this.currentSequence=void 0,this.sequenceIndex=0}get inSFCRoot(){return 2===this.mode&&0===this.stack.length}reset(){this.state=1,this.mode=0,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=1,this.inRCDATA=!1,this.currentSequence=void 0,this.newlines.length=0,this.delimiterOpen=sJ,this.delimiterClose=sG}getPos(e){let t=1,n=e+1,r=this.newlines.length,i=-1;if(r>100){let t=-1,n=r;for(;t+1>>1;this.newlines[r]=0;t--)if(e>this.newlines[t]){i=t;break}return i>=0&&(t=i+2,n=e-this.newlines[i]),{column:n,line:t,offset:e}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(e){60===e?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):this.inVPre||e!==this.delimiterOpen[0]||(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(e))}stateInterpolationOpen(e){if(e===this.delimiterOpen[this.delimiterIndex])if(this.delimiterIndex===this.delimiterOpen.length-1){let e=this.index+1-this.delimiterOpen.length;e>this.sectionStart&&this.cbs.ontext(this.sectionStart,e),this.state=3,this.sectionStart=e}else this.delimiterIndex++;else this.inRCDATA?(this.state=32,this.stateInRCDATA(e)):(this.state=1,this.stateText(e))}stateInterpolation(e){e===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(e))}stateInterpolationClose(e){e===this.delimiterClose[this.delimiterIndex]?this.delimiterIndex===this.delimiterClose.length-1?(this.cbs.oninterpolation(this.sectionStart,this.index+1),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):this.delimiterIndex++:(this.state=3,this.stateInterpolation(e))}stateSpecialStartSequence(e){let t=this.sequenceIndex===this.currentSequence.length;if(t?sZ(e):(32|e)===this.currentSequence[this.sequenceIndex]){if(!t)return void this.sequenceIndex++}else this.inRCDATA=!1;this.sequenceIndex=0,this.state=6,this.stateInTagName(e)}stateInRCDATA(e){if(this.sequenceIndex===this.currentSequence.length){if(62===e||sQ(e)){let t=this.index-this.currentSequence.length;if(this.sectionStart=e||(28===this.state?this.currentSequence===s0.CdataEnd?this.cbs.oncdata(this.sectionStart,e):this.cbs.oncomment(this.sectionStart,e):6===this.state||11===this.state||18===this.state||17===this.state||12===this.state||13===this.state||14===this.state||15===this.state||16===this.state||20===this.state||19===this.state||21===this.state||9===this.state||this.cbs.ontext(this.sectionStart,e))}emitCodePoint(e,t){}}(oR,{onerr:oJ,ontext(e,t){oF(oL(e,t),e,t)},ontextentity(e,t,n){oF(e,t,n)},oninterpolation(e,t){if(oE)return oF(oL(e,t),e,t);let n=e+oO.delimiterOpen.length,r=t-oO.delimiterClose.length;for(;sQ(ox.charCodeAt(n));)n++;for(;sQ(ox.charCodeAt(r-1));)r--;let i=oL(n,r);i.includes("&")&&(i=o_.decodeEntities(i,!1)),oq({type:5,content:oz(i,!1,oW(n,r)),loc:oW(e,t)})},onopentagname(e,t){let n=oL(e,t);oC={type:1,tag:n,ns:o_.getNamespace(n,oR[0],o_.ns),tagType:0,props:[],children:[],loc:oW(e-1,t),codegenNode:void 0}},onopentagend(e){o$(e)},onclosetag(e,t){let n=oL(e,t);if(!o_.isVoidTag(n)){let r=!1;for(let e=0;e0&&oR[0].loc.start.offset;for(let n=0;n<=e;n++)oD(oR.shift(),t,n(7===e.type?e.rawName:e.name)===t)},onattribend(e,t){oC&&ok&&(oK(ok.loc,t),0!==e&&(oT.includes("&")&&(oT=o_.decodeEntities(oT,!0)),6===ok.type?("class"===ok.name&&(oT=oH(oT).trim()),ok.value={type:2,content:oT,loc:1===e?oW(ow,oN):oW(ow-1,oN+1)},oO.inSFCRoot&&"template"===oC.tag&&"lang"===ok.name&&oT&&"html"!==oT&&oO.enterRCDATA(sY("{let i=t.start.offset+n,l=i+e.length;return oz(e,!1,oW(i,l),0,+!!r)},o={source:s(l.trim(),n.indexOf(l,i.length)),value:void 0,key:void 0,index:void 0,finalized:!1},a=i.trim().replace(oP,"").trim(),c=i.indexOf(a),u=a.match(oM);if(u){let e;a=a.replace(oM,"").trim();let t=u[1].trim();if(t&&(e=n.indexOf(t,c+a.length),o.key=s(t,e,!0)),u[2]){let r=u[2].trim();r&&(o.index=s(r,n.indexOf(r,o.key?e+t.length:c+a.length),!0))}}return a&&(o.value=s(a,c,!0)),o}(ok.exp)))),(7!==ok.type||"pre"!==ok.name)&&oC.props.push(ok)),oT="",ow=oN=-1},oncomment(e,t){o_.comments&&oq({type:3,content:oL(e,t),loc:oW(e-4,t+3)})},onend(){let e=ox.length;for(let t=0;t64&&n<91||s4(e)||o_.isBuiltInComponent&&o_.isBuiltInComponent(e)||o_.isNativeTag&&!o_.isNativeTag(e))return!0;for(let e=0;e=0;)n--;return n}let oj=new Set(["if","else","else-if","for","slot"]),oB=/\r\n/g;function oU(e){let t="preserve"!==o_.whitespace,n=!1;for(let r=0;r3!==e.type);return 1!==t.length||1!==t[0].type||ou(t[0])?null:t[0]}function oX(e,t){let{constantCache:n}=t;switch(e.type){case 1:if(0!==e.tagType)return 0;let r=n.get(e);if(void 0!==r)return r;let i=e.codegenNode;if(13!==i.type||i.isBlock&&"svg"!==e.tag&&"foreignObject"!==e.tag&&"math"!==e.tag)return 0;if(void 0!==i.patchFlag)return n.set(e,0),0;{let r=3,c=oZ(e,t);if(0===c)return n.set(e,0),0;c1)for(let i=0;i{l--};for(;lt===e:t=>e.test(t);return(e,r)=>{if(1===e.type){let{props:i}=e;if(3===e.tagType&&i.some(oa))return;let l=[];for(let s=0;s`${s$[e]}: _${s$[e]}`;function o3(e,t,{helper:n,push:r,newline:i,isTS:l}){let s=n("component"===t?su:sh);for(let n=0;n3;t.push("["),n&&t.indent(),o8(e,t,n),n&&t.deindent(),t.push("]")}function o8(e,t,n=!1,r=!0){let{push:i,newline:l}=t;for(let s=0;se||"null")}([a,c,u,i,h]),t),l(")"),f&&l(")"),p&&(l(", "),o5(p,t),l(")"))}(e,t);break;case 14:!function(e,t){let{push:n,helper:r,pure:i}=t,l=R(e.callee)?e.callee:r(e.callee);i&&n(o2),n(l+"(",-2,e),o8(e.arguments,t),n(")")}(e,t);break;case 15:!function(e,t){let{push:n,indent:r,deindent:i,newline:l}=t,{properties:s}=e;if(!s.length)return n("{}",-2,e);let o=s.length>1;n(o?"{":"{ "),o&&r();for(let e=0;e "),(a||o)&&(n("{"),r()),s?(a&&n("return "),E(s)?o4(s,t):o5(s,t)):o&&o5(o,t),(a||o)&&(i(),n("}")),c&&n(")")}(e,t);break;case 19:!function(e,t){let{test:n,consequent:r,alternate:i,newline:l}=e,{push:s,indent:o,deindent:a,newline:c}=t;if(4===n.type){let e,r=(e=n.content,!!s8.test(e));r&&s("("),o9(n,t),r&&s(")")}else s("("),o5(n,t),s(")");l&&o(),t.indentLevel++,l||s(" "),s("? "),o5(r,t),t.indentLevel--,l&&c(),l||s(" "),s(": ");let u=19===i.type;!u&&t.indentLevel++,o5(i,t),!u&&t.indentLevel--,l&&a(!0)}(e,t);break;case 20:!function(e,t){let{push:n,helper:r,indent:i,deindent:l,newline:s}=t,{needPauseTracking:o,needArraySpread:a}=e;a&&n("[...("),n(`_cache[${e.index}] || (`),o&&(i(),n(`${r(sA)}(-1`),e.inVOnce&&n(", true"),n("),"),s(),n("(")),n(`_cache[${e.index}] = `),o5(e.value,t),o&&(n(`).cacheIndex = ${e.index},`),s(),n(`${r(sA)}(1),`),s(),n(`_cache[${e.index}]`),l()),n(")"),a&&n(")]")}(e,t);break;case 21:o8(e.body,t,!0,!1)}}function o9(e,t){let{content:n,isStatic:r}=e;t.push(r?JSON.stringify(n):n,-3,e)}function o7(e,t){for(let n=0;n(function(e,t,n,r){if("else"!==t.name&&(!t.exp||!t.exp.content.trim())){let r=t.exp?t.exp.loc:e.loc;n.onError(s6(28,t.loc)),t.exp=sU("true",!1,r)}if("if"===t.name){var i;let l=at(e,t),s={type:9,loc:oW((i=e.loc).start.offset,i.end.offset),branches:[l]};if(n.replaceNode(s),r)return r(s,l,!0)}else{let i=n.parent.children,l=i.indexOf(e);for(;l-- >=-1;){let s=i[l];if(s&&oy(s)){n.removeNode(s);continue}if(s&&9===s.type){("else-if"===t.name||"else"===t.name)&&void 0===s.branches[s.branches.length-1].condition&&n.onError(s6(30,e.loc)),n.removeNode();let i=at(e,t);s.branches.push(i);let l=r&&r(s,i,!1);o0(i,n),l&&l(),n.currentNode=null}else n.onError(s6(30,e.loc));break}}})(e,t,n,(e,t,r)=>{let i=n.parent.children,l=i.indexOf(e),s=0;for(;l-- >=0;){let e=i[l];e&&9===e.type&&(s+=e.branches.length)}return()=>{r?e.codegenNode=an(t,s,n):function(e){for(;;)if(19===e.type)if(19!==e.alternate.type)return e;else e=e.alternate;else 20===e.type&&(e=e.value)}(e.codegenNode).alternate=an(t,s+e.branches.length-1,n)}}));function at(e,t){let n=3===e.tagType;return{type:10,loc:e.loc,condition:"else"===t.name?void 0:t.exp,children:n&&!or(e,"for")?e.children:[e],userKey:oi(e,"key"),isTemplateIf:n}}function an(e,t,n){return e.condition?sK(e.condition,ar(e,t,n),sq(n.helper(so),['""',"true"])):ar(e,t,n)}function ar(e,t,n){let{helper:r}=n,i=sB("key",sU(`${t}`,!1,sF,2)),{children:l}=e,s=l[0];if(1!==l.length||1!==s.type)if(1!==l.length||11!==s.type)return sD(n,r(l5),sj([i]),l,64,void 0,void 0,!0,!1,!1,e.loc);else{let e=s.codegenNode;return oh(e,i,n),e}{let e=s.codegenNode,t=14===e.type&&e.callee===sP?e.arguments[1].returns:e;return 13===t.type&&sz(t,n),oh(t,i,n),e}}let ai=o1("for",(e,t,n)=>{let{helper:r,removeHelper:i}=n;return function(e,t,n,r){if(!t.exp)return void n.onError(s6(31,t.loc));let i=t.forParseResult;if(!i)return void n.onError(s6(32,t.loc));al(i);let{scopes:l}=n,{source:s,value:o,key:a,index:c}=i,u={type:11,loc:t.loc,source:s,valueAlias:o,keyAlias:a,objectIndexAlias:c,parseResult:i,children:oc(e)?e.children:[e]};n.replaceNode(u),l.vFor++;let d=r&&r(u);return()=>{l.vFor--,d&&d()}}(e,t,n,t=>{let l=sq(r(sg),[t.source]),s=oc(e),o=or(e,"memo"),a=oi(e,"key",!1,!0);a&&a.type;let c=a&&(6===a.type?a.value?sU(a.value.content,!0):void 0:a.exp),u=a&&c?sB("key",c):null,d=4===t.source.type&&t.source.constType>0,h=d?64:a?128:256;return t.codegenNode=sD(n,r(l5),void 0,l,h,void 0,void 0,!0,!d,!1,e.loc),()=>{let a,{children:h}=t,p=1!==h.length||1!==h[0].type,f=ou(e)?e:s&&1===e.children.length&&ou(e.children[0])?e.children[0]:null;if(f)a=f.codegenNode,s&&u&&oh(a,u,n);else if(p)a=sD(n,r(l5),u?sj([u]):void 0,e.children,64,void 0,void 0,!0,void 0,!1);else{var g,m,y,b,_,S,x,C;a=h[0].codegenNode,s&&u&&oh(a,u,n),!d!==a.isBlock&&(a.isBlock?(i(sn),i((g=n.inSSR,m=a.isComponent,g||m?sr:si))):i((y=n.inSSR,b=a.isComponent,y||b?sl:ss))),(a.isBlock=!d,a.isBlock)?(r(sn),r((_=n.inSSR,S=a.isComponent,_||S?sr:si))):r((x=n.inSSR,C=a.isComponent,x||C?sl:ss))}if(o){let e=sW(as(t.parseResult,[sU("_cached")]));e.body={type:21,body:[sH(["const _memo = (",o.exp,")"]),sH(["if (_cached",...c?[" && _cached.key === ",c]:[],` && ${n.helperString(sL)}(_cached, _memo)) return _cached`]),sH(["const _item = ",a]),sU("_item.memo = _memo"),sU("return _item")],loc:sF},l.arguments.push(e,sU("_cache"),sU(String(n.cached.length))),n.cached.push(null)}else l.arguments.push(sW(as(t.parseResult),a,!0))}})});function al(e,t){e.finalized||(e.finalized=!0)}function as({value:e,key:t,index:n},r=[]){var i=[e,t,n,...r];let l=i.length;for(;l--&&!i[l];);return i.slice(0,l+1).map((e,t)=>e||sU("_".repeat(t+1),!1))}let ao=sU("undefined",!1),aa=(e,t)=>{if(1===e.type&&(1===e.tagType||3===e.tagType)){let n=or(e,"slot");if(n)return n.exp,t.scopes.vSlot++,()=>{t.scopes.vSlot--}}};function ac(e,t,n){let r=[sB("name",e),sB("fn",t)];return null!=n&&r.push(sB("key",sU(String(n),!0))),sj(r)}let au=new WeakMap,ad=(e,t)=>function(){let n,r,i,l,s;if(1!==(e=t.currentNode).type||0!==e.tagType&&1!==e.tagType)return;let{tag:o,props:a}=e,c=1===e.tagType,u=c?function(e,t,n=!1){let{tag:r}=e,i=af(r),l=oi(e,"is",!1,!0);if(l)if(i){let e;if(6===l.type?e=l.value&&sU(l.value.content,!0):(e=l.exp)||(e=sU("is",!1,l.arg.loc)),e)return sq(t.helper(sd),[e])}else 6===l.type&&l.value.content.startsWith("vue:")&&(r=l.value.content.slice(4));let s=s4(r)||t.isBuiltInComponent(r);return s?(n||t.helper(s),s):(t.helper(su),t.components.add(r),of(r,"component"))}(e,t):`"${o}"`,d=M(u)&&u.callee===sd,h=0,p=d||u===l9||u===l7||!c&&("svg"===o||"foreignObject"===o||"math"===o);if(a.length>0){let r=ah(e,t,void 0,c,d);n=r.props,h=r.patchFlag,l=r.dynamicPropNames;let i=r.directives;s=i&&i.length?sV(i.map(e=>(function(e,t){let n=[],r=au.get(e);r?n.push(t.helperString(r)):(t.helper(sh),t.directives.add(e.name),n.push(of(e.name,"directive")));let{loc:i}=e;if(e.exp&&n.push(e.exp),e.arg&&(e.exp||n.push("void 0"),n.push(e.arg)),Object.keys(e.modifiers).length){e.arg||(e.exp||n.push("void 0"),n.push("void 0"));let t=sU("true",!1,i);n.push(sj(e.modifiers.map(e=>sB(e,t)),i))}return sV(n,e.loc)})(e,t))):void 0,r.shouldUseBlock&&(p=!0)}if(e.children.length>0)if(u===se&&(p=!0,h|=1024),c&&u!==l9&&u!==se){let{slots:n,hasDynamicSlots:i}=function(e,t,n=(e,t,n,r)=>sW(e,n,!1,!0,n.length?n[0].loc:r)){t.helper(sR);let{children:r,loc:i}=e,l=[],s=[],o=t.scopes.vSlot>0||t.scopes.vFor>0,a=or(e,"slot",!0);if(a){let{arg:e,exp:t}=a;e&&!s3(e)&&(o=!0),l.push(sB(e||sU("default",!0),n(t,void 0,r,i)))}let c=!1,u=!1,d=[],h=new Set,p=0;for(let e=0;esB("default",n(e,void 0,t,i));c?d.length&&!d.every(ov)&&(u?t.onError(s6(39,d[0].loc)):l.push(e(void 0,d))):l.push(e(void 0,r))}let f=o?2:!function e(t){for(let n=0;n0,f=!1,g=0,m=!1,y=!1,b=!1,_=!1,S=!1,x=!1,k=[],T=e=>{u.length&&(d.push(sj(ap(u),a)),u=[]),e&&d.push(e)},w=()=>{t.scopes.vFor>0&&u.push(sB(sU("ref_for",!0),sU("true")))},N=({key:e,value:n})=>{if(s3(e)){let l=e.content,s=C(l);s&&(!r||i)&&"onclick"!==l.toLowerCase()&&"onUpdate:modelValue"!==l&&!F(l)&&(_=!0),s&&F(l)&&(x=!0),s&&14===n.type&&(n=n.arguments[0]),20===n.type||(4===n.type||8===n.type)&&oX(n,t)>0||("ref"===l?m=!0:"class"===l?y=!0:"style"===l?b=!0:"key"===l||k.includes(l)||k.push(l),r&&("class"===l||"style"===l)&&!k.includes(l)&&k.push(l))}else S=!0};for(let i=0;i"prop"===e.content)&&(g|=32);let x=t.directiveTransforms[n];if(x){let{props:n,needRuntime:r}=x(s,e,t);l||n.forEach(N),_&&i&&!s3(i)?T(sj(n,a)):u.push(...n),r&&(h.push(s),O(r)&&au.set(s,r))}else!D(n)&&(h.push(s),p&&(f=!0))}}if(d.length?(T(),s=d.length>1?sq(t.helper(sb),d,a):d[0]):u.length&&(s=sj(ap(u),a)),S?g|=16:(y&&!r&&(g|=2),b&&!r&&(g|=4),k.length&&(g|=8),_&&(g|=32)),!f&&(0===g||32===g)&&(m||x||h.length>0)&&(g|=512),!t.inSSR&&s)switch(s.type){case 15:let A=-1,E=-1,I=!1;for(let e=0;e{if(ou(e)){let{children:n,loc:r}=e,{slotName:i,slotProps:l}=function(e,t){let n,r='"default"',i=[];for(let t=0;t0){let{props:r,directives:l}=ah(e,t,i,!1,!1);n=r,l.length&&t.onError(s6(36,l[0].loc))}return{slotName:r,slotProps:n}}(e,t),s=[t.prefixIdentifiers?"_ctx.$slots":"$slots",i,"{}","undefined","true"],o=2;l&&(s[2]=l,o=3),n.length&&(s[3]=sW([],n,!1,!1,r),o=4),t.scopeId&&!t.slotted&&(o=5),s.splice(o),e.codegenNode=sq(t.helper(sm),s,r)}},am=(e,t,n,r)=>{let i,{loc:l,modifiers:s,arg:o}=e;if(!e.exp&&!s.length,4===o.type)if(o.isStatic){let e=o.content;e.startsWith("vue:")&&(e=`vnode-${e.slice(4)}`),i=sU(0!==t.tagType||e.startsWith("vnode")||!/[A-Z]/.test(e)?W(B(e)):`on:${e}`,!0,o.loc)}else i=sH([`${n.helperString(sN)}(`,o,")"]);else(i=o).children.unshift(`${n.helperString(sN)}(`),i.children.push(")");let a=e.exp;a&&!a.content.trim()&&(a=void 0);let c=n.cacheHandlers&&!a&&!n.inVOnce;if(a){let e,t=ot(a),n=!(t||(e=a,on.test(oe(e)))),r=a.content.includes(";");(n||c&&t)&&(a=sH([`${n?"$event":"(...args)"} => ${r?"{":"("}`,a,r?"}":")"]))}let u={props:[sB(i,a||sU("() => {}",!1,l))]};return r&&(u=r(u)),c&&(u.props[0].value=n.cache(u.props[0].value)),u.props.forEach(e=>e.key.isHandlerKey=!0),u},av=(e,t,n)=>{let{modifiers:r}=e,i=e.arg,{exp:l}=e;return l&&4===l.type&&!l.content.trim()&&(l=void 0),4!==i.type?(i.children.unshift("("),i.children.push(') || ""')):i.isStatic||(i.content=i.content?`${i.content} || ""`:'""'),r.some(e=>"camel"===e.content)&&(4===i.type?i.isStatic?i.content=B(i.content):i.content=`${n.helperString(sT)}(${i.content})`:(i.children.unshift(`${n.helperString(sT)}(`),i.children.push(")"))),!n.inSSR&&(r.some(e=>"prop"===e.content)&&ay(i,"."),r.some(e=>"attr"===e.content)&&ay(i,"^")),{props:[sB(i,l)]}},ay=(e,t)=>{4===e.type?e.isStatic?e.content=t+e.content:e.content=`\`${t}\${${e.content}}\``:(e.children.unshift(`'${t}' + (`),e.children.push(")"))},ab=(e,t)=>{if(0===e.type||1===e.type||11===e.type||10===e.type)return()=>{let n,r=e.children,i=!1;for(let e=0;e7===e.type&&!t.directiveTransforms[e.name]))))for(let e=0;e{if(1===e.type&&or(e,"once",!0)&&!a_.has(e)&&!t.inVOnce&&!t.inSSR)return a_.add(e),t.inVOnce=!0,t.helper(sA),()=>{t.inVOnce=!1;let e=t.currentNode;e.codegenNode&&(e.codegenNode=t.cache(e.codegenNode,!0,!0))}},ax=(e,t,n)=>{let r,{exp:i,arg:l}=e;if(!i)return n.onError(s6(41,e.loc)),aC();let s=i.loc.source.trim(),o=4===i.type?i.content:s,a=n.bindingMetadata[s];if("props"===a||"props-aliased"===a||"literal-const"===a||"setup-const"===a)return i.loc,aC();if(!o.trim()||!ot(i))return n.onError(s6(42,i.loc)),aC();let c=l||sU("modelValue",!0),u=l?s3(l)?`onUpdate:${B(l.content)}`:sH(['"onUpdate:" + ',l]):"onUpdate:modelValue",d=n.isTS?"($event: any)":"$event";r=sH([`${d} => ((`,i,") = $event)"]);let h=[sB(c,e.exp),sB(u,r)];if(e.modifiers.length&&1===t.tagType){let t=e.modifiers.map(e=>e.content).map(e=>(s8.test(e)?JSON.stringify(e):e)+": true").join(", "),n=l?s3(l)?`${l.content}Modifiers`:sH([l,' + "Modifiers"']):"modelModifiers";h.push(sB(n,sU(`{ ${t} }`,!1,e.loc,2)))}return aC(h)};function aC(e=[]){return{props:e}}let ak=new WeakSet,aT=(e,t)=>{if(1===e.type){let n=or(e,"memo");if(!(!n||ak.has(e))&&!t.inSSR)return ak.add(e),()=>{let r=e.codegenNode||t.currentNode.codegenNode;r&&13===r.type&&(1!==e.tagType&&sz(r,t),e.codegenNode=sq(t.helper(sP),[n.exp,sW(void 0,r),"_cache",String(t.cached.length)]),t.cached.push(null))}}},aw=(e,t)=>{if(1===e.type){for(let n of e.props)if(7===n.type&&"bind"===n.name&&(!n.exp||4===n.exp.type&&!n.exp.content.trim())&&n.arg){let e=n.arg;if(4===e.type&&e.isStatic){let t=B(e.content);(s5.test(t[0])||"-"===t[0])&&(n.exp=sU(t,!1,e.loc))}else t.onError(s6(53,e.loc)),n.exp=sU("",!0,e.loc)}}},aN=Symbol(""),aA=Symbol(""),aE=Symbol(""),aI=Symbol(""),aR=Symbol(""),aO=Symbol(""),aM=Symbol(""),aP=Symbol(""),aL=Symbol(""),a$=Symbol("");Object.getOwnPropertySymbols(r={[aN]:"vModelRadio",[aA]:"vModelCheckbox",[aE]:"vModelText",[aI]:"vModelSelect",[aR]:"vModelDynamic",[aO]:"withModifiers",[aM]:"withKeys",[aP]:"vShow",[aL]:"Transition",[a$]:"TransitionGroup"}).forEach(e=>{s$[e]=r[e]});let aF={parseMode:"html",isVoidTag:ea,isNativeTag:e=>el(e)||es(e)||eo(e),isPreTag:e=>"pre"===e,isIgnoreNewlineTag:e=>"pre"===e||"textarea"===e,decodeEntities:function(e,t=!1){return(f||(f=document.createElement("div")),t)?(f.innerHTML=``,f.children[0].getAttribute("foo")):(f.innerHTML=e,f.textContent)},isBuiltInComponent:e=>"Transition"===e||"transition"===e?aL:"TransitionGroup"===e||"transition-group"===e?a$:void 0,getNamespace(e,t,n){let r=t?t.ns:n;if(t&&2===r)if("annotation-xml"===t.tag){if("svg"===e)return 1;t.props.some(e=>6===e.type&&"encoding"===e.name&&null!=e.value&&("text/html"===e.value.content||"application/xhtml+xml"===e.value.content))&&(r=0)}else/^m(?:[ions]|text)$/.test(t.tag)&&"mglyph"!==e&&"malignmark"!==e&&(r=0);else t&&1===r&&("foreignObject"===t.tag||"desc"===t.tag||"title"===t.tag)&&(r=0);if(0===r){if("svg"===e)return 1;if("math"===e)return 2}return r}},aD=y("passive,once,capture"),aV=y("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),aj=y("left,right"),aB=y("onkeyup,onkeydown,onkeypress"),aU=(e,t)=>s3(e)&&"onclick"===e.content.toLowerCase()?sU(t,!0):4!==e.type?sH(["(",e,`) === "onClick" ? "${t}" : (`,e,")"]):e,aH=(e,t)=>{1===e.type&&0===e.tagType&&("script"===e.tag||"style"===e.tag)&&t.removeNode()},aq=[e=>{1===e.type&&e.props.forEach((t,n)=>{let r,i;6===t.type&&"style"===t.name&&t.value&&(e.props[n]={type:7,name:"bind",arg:sU("style",!0,t.loc),exp:(r=t.value.content,i=t.loc,sU(JSON.stringify(er(r)),!1,i,3)),modifiers:[],loc:t.loc})})}],aW={cloak:()=>({props:[]}),html:(e,t,n)=>{let{exp:r,loc:i}=e;return r||n.onError(s6(54,i)),t.children.length&&(n.onError(s6(55,i)),t.children.length=0),{props:[sB(sU("innerHTML",!0,i),r||sU("",!0))]}},text:(e,t,n)=>{let{exp:r,loc:i}=e;return r||n.onError(s6(56,i)),t.children.length&&(n.onError(s6(57,i)),t.children.length=0),{props:[sB(sU("textContent",!0),r?oX(r,n)>0?r:sq(n.helperString(sy),[r],i):sU("",!0))]}},model:(e,t,n)=>{let r=ax(e,t,n);if(!r.props.length||1===t.tagType)return r;e.arg&&n.onError(s6(59,e.arg.loc));let{tag:i}=t,l=n.isCustomElement(i);if("input"===i||"textarea"===i||"select"===i||l){let s=aE,o=!1;if("input"===i||l){let r=oi(t,"type");if(r){if(7===r.type)s=aR;else if(r.value)switch(r.value.content){case"radio":s=aN;break;case"checkbox":s=aA;break;case"file":o=!0,n.onError(s6(60,e.loc))}}else t.props.some(e=>7===e.type&&"bind"===e.name&&(!e.arg||4!==e.arg.type||!e.arg.isStatic))&&(s=aR)}else"select"===i&&(s=aI);o||(r.needRuntime=n.helper(s))}else n.onError(s6(58,e.loc));return r.props=r.props.filter(e=>4!==e.key.type||"modelValue"!==e.key.content),r},on:(e,t,n)=>am(e,t,n,t=>{let{modifiers:r}=e;if(!r.length)return t;let{key:i,value:l}=t.props[0],{keyModifiers:s,nonKeyModifiers:o,eventOptionModifiers:a}=((e,t,n,r)=>{let i=[],l=[],s=[];for(let n=0;n
{let{exp:r,loc:i}=e;return r||n.onError(s6(62,i)),{props:[],needRuntime:n.helper(aP)}}},aK=Object.create(null);function az(e,t){if(!R(e))if(!e.nodeType)return S;else e=e.innerHTML;let n=e+JSON.stringify(t,(e,t)=>"function"==typeof t?t.toString():t),r=aK[n];if(r)return r;if("#"===e[0]){let t=document.querySelector(e);e=t?t.innerHTML:""}let i=T({hoistStatic:!0,onError:void 0,onWarn:S},t);!i.isCustomElement&&"u">typeof customElements&&(i.isCustomElement=e=>!!customElements.get(e));let{code:l}=function(e,t={}){return function(e,t={}){var n;let r,i=t.onError||s1,l="module"===t.mode;!0===t.prefixIdentifiers?i(s6(48)):l&&i(s6(49)),t.cacheHandlers&&i(s6(50)),t.scopeId&&!l&&i(s6(51));let s=T({},t,{prefixIdentifiers:!1}),o=R(e)?function(e,t){if(oO.reset(),oC=null,ok=null,oT="",ow=-1,oN=-1,oR.length=0,ox=e,o_=T({},ob),t){let e;for(e in t)null!=t[e]&&(o_[e]=t[e])}oO.mode="html"===o_.parseMode?1:2*("sfc"===o_.parseMode),oO.inXML=1===o_.ns||2===o_.ns;let n=t&&t.delimiters;n&&(oO.delimiterOpen=sY(n[0]),oO.delimiterClose=sY(n[1]));let r=oS=function(e,t=""){return{type:0,source:t,children:e,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:[],temps:0,codegenNode:void 0,loc:sF}}([],e);return oO.parse(ox),r.loc=oW(0,e.length),r.children=oU(r.children),oS=null,r}(e,s):e,[a,c]=[[aw,aS,ae,aT,ai,ag,ad,aa,ab],{on:am,bind:av,model:ax}];return r=function(e,{filename:t="",prefixIdentifiers:n=!1,hoistStatic:r=!1,hmr:i=!1,cacheHandlers:l=!1,nodeTransforms:s=[],directiveTransforms:o={},transformHoist:a=null,isBuiltInComponent:c=S,isCustomElement:u=S,expressionPlugins:d=[],scopeId:h=null,slotted:p=!0,ssr:f=!1,inSSR:g=!1,ssrCssVars:m="",bindingMetadata:y=b,inline:_=!1,isTS:x=!1,onError:C=s1,onWarn:k=s2,compatConfig:T}){let w=t.replace(/\?.*$/,"").match(/([^/\\]+)\.\w+$/),N={filename:t,selfName:w&&q(B(w[1])),prefixIdentifiers:n,hoistStatic:r,hmr:i,cacheHandlers:l,nodeTransforms:s,directiveTransforms:o,transformHoist:a,isBuiltInComponent:c,isCustomElement:u,expressionPlugins:d,scopeId:h,slotted:p,ssr:f,inSSR:g,ssrCssVars:m,bindingMetadata:y,inline:_,isTS:x,onError:C,onWarn:k,compatConfig:T,root:e,helpers:new Map,components:new Set,directives:new Set,hoists:[],imports:[],cached:[],constantCache:new WeakMap,temps:0,identifiers:Object.create(null),scopes:{vFor:0,vSlot:0,vPre:0,vOnce:0},parent:null,grandParent:null,currentNode:e,childIndex:0,inVOnce:!1,helper(e){let t=N.helpers.get(e)||0;return N.helpers.set(e,t+1),e},removeHelper(e){let t=N.helpers.get(e);if(t){let n=t-1;n?N.helpers.set(e,n):N.helpers.delete(e)}},helperString:e=>`_${s$[N.helper(e)]}`,replaceNode(e){N.parent.children[N.childIndex]=N.currentNode=e},removeNode(e){let t=N.parent.children,n=e?t.indexOf(e):N.currentNode?N.childIndex:-1;e&&e!==N.currentNode?N.childIndex>n&&(N.childIndex--,N.onNodeRemoved()):(N.currentNode=null,N.onNodeRemoved()),N.parent.children.splice(n,1)},onNodeRemoved:S,addIdentifiers(e){},removeIdentifiers(e){},hoist(e){R(e)&&(e=sU(e)),N.hoists.push(e);let t=sU(`_hoisted_${N.hoists.length}`,!1,e.loc,2);return t.hoisted=e,t},cache(e,t=!1,n=!1){let r=function(e,t,n=!1,r=!1){return{type:20,index:e,value:t,needPauseTracking:n,inVOnce:r,needArraySpread:!1,loc:sF}}(N.cached.length,e,t,n);return N.cached.push(r),r}};return N}(o,n=T({},s,{nodeTransforms:[...a,...t.nodeTransforms||[]],directiveTransforms:T({},c,t.directiveTransforms||{})})),o0(o,r),n.hoistStatic&&function e(t,n,r,i=!1,l=!1){let{children:s}=t,o=[];for(let n=0;n0){if(e>=2){a.codegenNode.patchFlag=-1,o.push(a);continue}}else{let e=a.codegenNode;if(13===e.type){let t=e.patchFlag;if((void 0===t||512===t||1===t)&&oZ(a,r)>=2){let t=oY(a);t&&(e.props=r.hoist(t))}e.dynamicProps&&(e.dynamicProps=r.hoist(e.dynamicProps))}}}else if(12===a.type&&(i?0:oX(a,r))>=2){14===a.codegenNode.type&&a.codegenNode.arguments.length>0&&a.codegenNode.arguments.push("-1"),o.push(a);continue}if(1===a.type){let n=1===a.tagType;n&&r.scopes.vSlot++,e(a,t,r,!1,l),n&&r.scopes.vSlot--}else if(11===a.type)e(a,t,r,1===a.children.length,!0);else if(9===a.type)for(let n=0;ne.key===t||e.key.content===t);return n&&n.value}}o.length&&r.transformHoist&&r.transformHoist(s,r,t)}(o,void 0,r,!!oG(o)),n.ssr||function(e,t){let{helper:n}=t,{children:r}=e;if(1===r.length){let n=oG(e);if(n&&n.codegenNode){let r=n.codegenNode;13===r.type&&sz(r,t),e.codegenNode=r}else e.codegenNode=r[0]}else r.length>1&&(e.codegenNode=sD(t,n(l5),void 0,e.children,64,void 0,void 0,!0,void 0,!1))}(o,r),o.helpers=new Set([...r.helpers.keys()]),o.components=[...r.components],o.directives=[...r.directives],o.imports=r.imports,o.hoists=r.hoists,o.temps=r.temps,o.cached=r.cached,o.transformed=!0,function(e,t={}){let n=function(e,{mode:t="function",prefixIdentifiers:n="module"===t,sourceMap:r=!1,filename:i="template.vue.html",scopeId:l=null,optimizeImports:s=!1,runtimeGlobalName:o="Vue",runtimeModuleName:a="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:u=!1,isTS:d=!1,inSSR:h=!1}){let p={mode:t,prefixIdentifiers:n,sourceMap:r,filename:i,scopeId:l,optimizeImports:s,runtimeGlobalName:o,runtimeModuleName:a,ssrRuntimeModuleName:c,ssr:u,isTS:d,inSSR:h,source:e.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper:e=>`_${s$[e]}`,push(e,t=-2,n){p.code+=e},indent(){f(++p.indentLevel)},deindent(e=!1){e?--p.indentLevel:f(--p.indentLevel)},newline(){f(p.indentLevel)}};function f(e){p.push(`
+`+" ".repeat(e),0)}return p}(e,t);t.onContextCreated&&t.onContextCreated(n);let{mode:r,push:i,prefixIdentifiers:l,indent:s,deindent:o,newline:a,ssr:c}=n,u=Array.from(e.helpers),d=u.length>0,h=!l&&"module"!==r;!function(e,t){let{push:n,newline:r,runtimeGlobalName:i}=t,l=Array.from(e.helpers);if(l.length>0&&(n(`const _Vue = ${i}
+`,-1),e.hoists.length)){let e=[sl,ss,so,sa,sc].filter(e=>l.includes(e)).map(o6).join(", ");n(`const { ${e} } = _Vue
+`,-1)}(function(e,t){if(!e.length)return;t.pure=!0;let{push:n,newline:r}=t;r();for(let i=0;i0)&&a()),e.directives.length&&(o3(e.directives,"directive",n),e.temps>0&&a()),e.temps>0){i("let ");for(let t=0;t0?", ":""}_temp${t}`)}return(e.components.length||e.directives.length||e.temps)&&(i(`
+`,0),a()),c||i("return "),e.codegenNode?o5(e.codegenNode,n):i("null"),h&&(o(),i("}")),o(),i("}"),{ast:e,code:n.code,preamble:"",map:n.map?n.map.toJSON():void 0}}(o,s)}(e,T({},aF,t,{nodeTransforms:[aH,...aq,...t.nodeTransforms||[]],directiveTransforms:T({},aW,t.directiveTransforms||{}),transformHoist:null}))}(e,i),s=Function(l)();return s._rc=!0,aK[n]=s}return iM(az),e.BaseTransition=nb,e.BaseTransitionPropsValidators=nm,e.Comment=r9,e.DeprecationTypes=null,e.EffectScope=em,e.ErrorCodes={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE",APP_UNMOUNT_CLEANUP:16,16:"APP_UNMOUNT_CLEANUP"},e.ErrorTypeStrings=null,e.Fragment=r8,e.KeepAlive={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){let n=iN(),r=n.ctx,i=new Map,l=new Set,s=null,o=n.suspense,{renderer:{p:a,m:c,um:u,o:{createElement:d}}}=r,h=d("div");function p(e){nG(e),u(e,n,o,!0)}function f(e){i.forEach((t,n)=>{let r=iD(nU(t)?t.type.__asyncResolved||{}:t.type);r&&!e(r)&&g(n)})}function g(e){let t=i.get(e);!t||s&&iu(t,s)?s&&nG(s):p(t),i.delete(e),l.delete(e)}r.activate=(e,t,n,r,i)=>{let l=e.component;c(e,t,n,0,o),a(l.vnode,e,t,n,l,o,r,e.slotScopeIds,i),rW(()=>{l.isDeactivated=!1,l.a&&z(l.a);let t=e.props&&e.props.onVnodeMounted;t&&iC(t,l.parent,e)},o)},r.deactivate=e=>{let t=e.component;rZ(t.m),rZ(t.a),c(e,h,null,1,o),rW(()=>{t.da&&z(t.da);let n=e.props&&e.props.onVnodeUnmounted;n&&iC(n,t.parent,e),t.isDeactivated=!0},o)},t7(()=>[e.include,e.exclude],([e,t])=>{e&&f(t=>nW(e,t)),t&&f(e=>!nW(t,e))},{flush:"post",deep:!0});let m=null,y=()=>{null!=m&&(rY(n.subTree.type)?rW(()=>{i.set(m,nX(n.subTree))},n.subTree.suspense):i.set(m,nX(n.subTree)))};return n0(y),n2(y),n6(()=>{i.forEach(e=>{let{subTree:t,suspense:r}=n,i=nX(t);if(e.type===i.type&&e.key===i.key){nG(i);let e=i.component.da;e&&rW(e,r);return}p(e)})}),()=>{if(m=null,!t.default)return s=null;let n=t.default(),r=n[0];if(n.length>1)return s=null,n;if(!ic(r)||!(4&r.shapeFlag)&&!(128&r.shapeFlag))return s=null,r;let o=nX(r);if(o.type===r9)return s=null,o;let a=o.type,c=iD(nU(o)?o.type.__asyncResolved||{}:a),{include:u,exclude:d,max:h}=e;if(u&&(!c||!nW(u,c))||d&&c&&nW(d,c))return o.shapeFlag&=-257,s=o,r;let p=null==o.key?a:o.key,f=i.get(p);return o.el&&(o=iv(o),128&r.shapeFlag&&(r.ssContent=o)),m=p,f?(o.el=f.el,o.component=f.component,o.transition&&nk(o,o.transition),o.shapeFlag|=512,l.delete(p),l.add(p)):(l.add(p),h&&l.size>parseInt(h,10)&&g(l.values().next().value)),o.shapeFlag|=256,s=o,rY(r.type)?r:o}}},e.ReactiveEffect=ey,e.Static=r7,e.Suspense={name:"Suspense",__isSuspense:!0,process(e,t,n,r,i,l,s,o,a,c){if(null==e)!function(e,t,n,r,i,l,s,o,a){let{p:c,o:{createElement:u}}=a,d=u("div"),h=e.suspense=r2(e,i,r,t,d,n,l,s,o,a);c(null,h.pendingBranch=e.ssContent,d,null,r,h,l,s),h.deps>0?(r1(e,"onPending"),r1(e,"onFallback"),c(null,e.ssFallback,t,n,r,null,l,s),r4(h,e.ssFallback)):h.resolve(!1,!0)}(t,n,r,i,l,s,o,a,c);else{if(l&&l.deps>0&&!e.suspense.isInFallback){t.suspense=e.suspense,t.suspense.vnode=t,t.el=e.el;return}!function(e,t,n,r,i,l,s,o,{p:a,um:c,o:{createElement:u}}){let d=t.suspense=e.suspense;d.vnode=t,t.el=e.el;let h=t.ssContent,p=t.ssFallback,{activeBranch:f,pendingBranch:g,isInFallback:m,isHydrating:y}=d;if(g)d.pendingBranch=h,iu(g,h)?(a(g,h,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0?d.resolve():m&&!y&&(a(f,p,n,r,i,null,l,s,o),r4(d,p))):(d.pendingId=r0++,y?(d.isHydrating=!1,d.activeBranch=g):c(g,i,d),d.deps=0,d.effects.length=0,d.hiddenContainer=u("div"),m?(a(null,h,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0?d.resolve():(a(f,p,n,r,i,null,l,s,o),r4(d,p))):f&&iu(f,h)?(a(f,h,n,r,i,d,l,s,o),d.resolve(!0)):(a(null,h,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0&&d.resolve()));else if(f&&iu(f,h))a(f,h,n,r,i,d,l,s,o),r4(d,h);else if(r1(t,"onPending"),d.pendingBranch=h,512&h.shapeFlag?d.pendingId=h.component.suspenseId:d.pendingId=r0++,a(null,h,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0)d.resolve();else{let{timeout:e,pendingId:t}=d;e>0?setTimeout(()=>{d.pendingId===t&&d.fallback(p)},e):0===e&&d.fallback(p)}}(e,t,n,r,i,s,o,a,c)}},hydrate:function(e,t,n,r,i,l,s,o,a){let c=t.suspense=r2(t,r,n,e.parentNode,document.createElement("div"),null,i,l,s,o,!0),u=a(e,c.pendingBranch=t.ssContent,n,c,l,s);return 0===c.deps&&c.resolve(!1,!0),u},normalize:function(e){let{shapeFlag:t,children:n}=e,r=32&t;e.ssContent=r6(r?n.default:n),e.ssFallback=r?r6(n.fallback):ig(r9)}},e.Teleport=na,e.Text=r5,e.TrackOpTypes={GET:"get",HAS:"has",ITERATE:"iterate"},e.Transition=iY,e.TransitionGroup=lR,e.TriggerOpTypes={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"},e.VueElement=lT,e.assertNumber=function(e,t){},e.callWithAsyncErrorHandling=tD,e.callWithErrorHandling=tF,e.camelize=B,e.capitalize=q,e.cloneVNode=iv,e.compatUtils=null,e.compile=az,e.computed=iV,e.createApp=l6,e.createBlock=ia,e.createCommentVNode=function(e="",t=!1){return t?(ir(),ia(r9,null,e)):ig(r9,null,e)},e.createElementBlock=function(e,t,n,r,i,l){return io(ip(e,t,n,r,i,l,!0))},e.createElementVNode=ip,e.createHydrationRenderer=rK,e.createPropsRestProxy=function(e,t){let n={};for(let r in e)t.includes(r)||Object.defineProperty(n,r,{enumerable:!0,get:()=>e[r]});return n},e.createRenderer=function(e){return rz(e)},e.createSSRApp=l3,e.createSlots=function(e,t){for(let n=0;n{let t=r.fn(...e);return t&&(t.key=r.key),t}:r.fn)}return e},e.createStaticVNode=function(e,t){let n=ig(r7,null,e);return n.staticCount=t,n},e.createTextVNode=iy,e.createVNode=ig,e.customRef=tE,e.defineAsyncComponent=function(e){let t;I(e)&&(e={loader:e});let{loader:n,loadingComponent:r,errorComponent:i,delay:l=200,hydrate:s,timeout:o,suspensible:a=!0,onError:c}=e,u=null,d=0,h=()=>{let e;return u||(e=u=n().catch(e=>{if(e=e instanceof Error?e:Error(String(e)),c)return new Promise((t,n)=>{c(e,()=>t((d++,u=null,h())),()=>n(e),d+1)});throw e}).then(n=>e!==u&&u?u:(n&&(n.__esModule||"Module"===n[Symbol.toStringTag])&&(n=n.default),t=n,n)))};return nw({name:"AsyncComponentWrapper",__asyncLoader:h,__asyncHydrate(e,n,r){let i=!1;(n.bu||(n.bu=[])).push(()=>i=!0);let l=()=>{i||r()},o=s?()=>{let t=s(l,t=>(function(e,t){if(nL(e)&&"["===e.data){let n=1,r=e.nextSibling;for(;r;){if(1===r.nodeType){if(!1===t(r))break}else if(nL(r))if("]"===r.data){if(0==--n)break}else"["===r.data&&n++;r=r.nextSibling}}else t(e)})(e,t));t&&(n.bum||(n.bum=[])).push(t)}:l;t?o():h().then(()=>!n.isUnmounted&&o())},get __asyncResolved(){return t},setup(){let e=iw;if(nN(e),t)return()=>nH(t,e);let n=t=>{u=null,tV(t,e,13,!i)};if(a&&e.suspense)return h().then(t=>()=>nH(t,e)).catch(e=>(n(e),()=>i?ig(i,{error:e}):null));let s=tS(!1),c=tS(),d=tS(!!l);return l&&setTimeout(()=>{d.value=!1},l),null!=o&&setTimeout(()=>{if(!s.value&&!c.value){let e=Error(`Async component timed out after ${o}ms.`);n(e),c.value=e}},o),h().then(()=>{s.value=!0,e.parent&&nq(e.parent.vnode)&&e.parent.update()}).catch(e=>{n(e),c.value=e}),()=>s.value&&t?nH(t,e):c.value&&i?ig(i,{error:c.value}):r&&!d.value?nH(r,e):void 0}})},e.defineComponent=nw,e.defineCustomElement=lC,e.defineEmits=function(){return null},e.defineExpose=function(e){},e.defineModel=function(){},e.defineOptions=function(e){},e.defineProps=function(){return null},e.defineSSRCustomElement=(e,t)=>lC(e,t,l3),e.defineSlots=function(){return null},e.devtools=void 0,e.effect=function(e,t){e.effect instanceof ey&&(e=e.effect.fn);let n=new ey(e);t&&T(n,t);try{n.run()}catch(e){throw n.stop(),e}let r=n.run.bind(n);return r.effect=n,r},e.effectScope=function(e){return new em(e)},e.getCurrentInstance=iN,e.getCurrentScope=function(){return l},e.getCurrentWatcher=function(){return g},e.getTransitionRawChildren=nT,e.guardReactiveProps=im,e.h=ij,e.handleError=tV,e.hasInjectionContext=function(){return!!(iN()||rx)},e.hydrate=(...e)=>{l1().hydrate(...e)},e.hydrateOnIdle=(e=1e4)=>t=>{let n=nj(t,{timeout:e});return()=>nB(n)},e.hydrateOnInteraction=(e=[])=>(t,n)=>{R(e)&&(e=[e]);let r=!1,i=e=>{r||(r=!0,l(),t(),e.target.dispatchEvent(new e.constructor(e.type,e)))},l=()=>{n(t=>{for(let n of e)t.removeEventListener(n,i)})};return n(t=>{for(let n of e)t.addEventListener(n,i,{once:!0})}),l},e.hydrateOnMediaQuery=e=>t=>{if(e){let n=matchMedia(e);if(!n.matches)return n.addEventListener("change",t,{once:!0}),()=>n.removeEventListener("change",t);t()}},e.hydrateOnVisible=e=>(t,n)=>{let r=new IntersectionObserver(e=>{for(let n of e)if(n.isIntersecting){r.disconnect(),t();break}},e);return n(e=>{if(e instanceof Element){if(function(e){let{top:t,left:n,bottom:r,right:i}=e.getBoundingClientRect(),{innerHeight:l,innerWidth:s}=window;return(t>0&&t0&&r0&&n0&&ir.disconnect()},e.initCustomFormatter=function(){},e.initDirectivesForSSR=S,e.inject=t8,e.isMemoSame=iB,e.isProxy=tg,e.isReactive=th,e.isReadonly=tp,e.isRef=t_,e.isRuntimeOnly=()=>!d,e.isShallow=tf,e.isVNode=ic,e.markRaw=tv,e.mergeDefaults=function(e,t){let n=rc(e);for(let e in t){if(e.startsWith("__skip"))continue;let r=n[e];r?E(r)||I(r)?r=n[e]={type:r,default:t[e]}:r.default=t[e]:null===r&&(r=n[e]={default:t[e]}),r&&t[`__skip_${e}`]&&(r.skipFactory=!0)}return n},e.mergeModels=function(e,t){return e&&t?E(e)&&E(t)?e.concat(t):T({},rc(e),rc(t)):e||t},e.mergeProps=ix,e.nextTick=tz,e.nodeOps=iz,e.normalizeClass=ei,e.normalizeProps=function(e){if(!e)return null;let{class:t,style:n}=e;return t&&!R(t)&&(e.class=ei(t)),n&&(e.style=Y(n)),e},e.normalizeStyle=Y,e.onActivated=nK,e.onBeforeMount=nY,e.onBeforeUnmount=n6,e.onBeforeUpdate=n1,e.onDeactivated=nz,e.onErrorCaptured=n9,e.onMounted=n0,e.onRenderTracked=n5,e.onRenderTriggered=n8,e.onScopeDispose=function(e,t=!1){l&&l.cleanups.push(e)},e.onServerPrefetch=n4,e.onUnmounted=n3,e.onUpdated=n2,e.onWatcherCleanup=tL,e.openBlock=ir,e.patchProp=lS,e.popScopeId=function(){t1=null},e.provide=t4,e.proxyRefs=tN,e.pushScopeId=function(e){t1=e},e.queuePostFlushCb=tX,e.reactive=ta,e.readonly=tu,e.ref=tS,e.registerRuntimeCompiler=iM,e.render=l2,e.renderList=function(e,t,n,r){let i,l=n&&n[r],s=E(e);if(s||R(e)){let n=s&&th(e),r=!1,o=!1;n&&(r=!tf(e),o=tp(e),e=eU(e)),i=Array(e.length);for(let n=0,s=e.length;nt(e,n,void 0,l&&l[n]));else{let n=Object.keys(e);i=Array(n.length);for(let r=0,s=n.length;r0;return"default"!==t&&(n.name=t),ir(),ia(r8,null,[ig("slot",n,r&&r())],e?-2:64)}let l=e[t];l&&l._c&&(l._d=!1),ir();let s=l&&function e(t){return t.some(t=>!ic(t)||t.type!==r9&&(t.type!==r8||!!e(t.children)))?t:null}(l(n)),o=n.key||s&&s.key,a=ia(r8,{key:(o&&!O(o)?o:`_${t}`)+(!s&&r?"_fb":"")},s||(r?r():[]),s&&1===e._?64:-2);return!i&&a.scopeId&&(a.slotScopeIds=[a.scopeId+"-s"]),l&&l._c&&(l._d=!0),a},e.resolveComponent=function(e,t){return rt(n7,e,!0,t)||e},e.resolveDirective=function(e){return rt("directives",e)},e.resolveDynamicComponent=function(e){return R(e)?rt(n7,e,!1)||e:e||re},e.resolveFilter=null,e.resolveTransitionHooks=nS,e.setBlockTracking=is,e.setDevtoolsHook=S,e.setTransitionHooks=nk,e.shallowReactive=tc,e.shallowReadonly=function(e){return td(e,!0,e8,tr,to)},e.shallowRef=tx,e.ssrContextKey=t5,e.ssrUtils=null,e.stop=function(e){e.effect.stop()},e.toDisplayString=ep,e.toHandlerKey=W,e.toHandlers=function(e,t){let n={};for(let r in e)n[t&&/[A-Z]/.test(r)?`on:${r}`:W(r)]=e[r];return n},e.toRaw=tm,e.toRef=function(e,t,n){if(t_(e))return e;if(I(e))return new tR(e);if(!M(e)||!(arguments.length>1))return tS(e);return new tI(e,t,n)},e.toRefs=function(e){let t=E(e)?Array(e.length):{};for(let n in e)t[n]=new tI(e,n,void 0);return t},e.toValue=function(e){return I(e)?e():tT(e)},e.transformVNodeArgs=function(e){},e.triggerRef=function(e){e.dep&&e.dep.trigger()},e.unref=tT,e.useAttrs=function(){return ra().attrs},e.useCssModule=function(e="$style"){return b},e.useCssVars=function(e){let t=iN();if(!t)return;let n=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(e=>ls(e,n))},r=()=>{let r=e(t.proxy);t.ce?ls(t.ce,r):function e(t,n){if(128&t.shapeFlag){let r=t.suspense;t=r.activeBranch,r.pendingBranch&&!r.isHydrating&&r.effects.push(()=>{e(r.activeBranch,n)})}for(;t.component;)t=t.component.subTree;if(1&t.shapeFlag&&t.el)ls(t.el,n);else if(t.type===r8)t.children.forEach(t=>e(t,n));else if(t.type===r7){let{el:e,anchor:r}=t;for(;e&&(ls(e,n),e!==r);)e=e.nextSibling}}(t.subTree,r),n(r)};n1(()=>{tX(r)}),n0(()=>{t7(r,S,{flush:"post"});let e=new MutationObserver(r);e.observe(t.subTree.el.parentNode,{childList:!0}),n3(()=>e.disconnect())})},e.useHost=lw,e.useId=function(){let e=iN();return e?(e.appContext.config.idPrefix||"v")+"-"+e.ids[0]+e.ids[1]++:""},e.useModel=function(e,t,n=b){let r=iN(),i=B(t),l=H(t),s=rC(e,i),o=tE((s,o)=>{let a,c,u=b;return t9(()=>{let t=e[i];K(a,t)&&(a=t,o())}),{get:()=>(s(),n.get?n.get(a):a),set(e){let s=n.set?n.set(e):e;if(!K(s,a)&&!(u!==b&&K(e,u)))return;let d=r.vnode.props;d&&(t in d||i in d||l in d)&&(`onUpdate:${t}`in d||`onUpdate:${i}`in d||`onUpdate:${l}`in d)||(a=e,o()),r.emit(`update:${t}`,s),K(e,s)&&K(e,u)&&!K(s,c)&&o(),u=e,c=s}}});return o[Symbol.iterator]=()=>{let e=0;return{next:()=>e<2?{value:e++?s||b:o,done:!1}:{done:!0}}},o},e.useSSRContext=()=>{},e.useShadowRoot=function(){let e=lw();return e&&e.shadowRoot},e.useSlots=function(){return ra().slots},e.useTemplateRef=function(e){let t=iN(),n=tx(null);return t&&Object.defineProperty(t.refs===b?t.refs={}:t.refs,e,{enumerable:!0,get:()=>n.value,set:e=>n.value=e}),n},e.useTransitionState=nf,e.vModelCheckbox=lU,e.vModelDynamic={created(e,t,n){lG(e,t,n,null,"created")},mounted(e,t,n){lG(e,t,n,null,"mounted")},beforeUpdate(e,t,n,r){lG(e,t,n,r,"beforeUpdate")},updated(e,t,n,r){lG(e,t,n,r,"updated")}},e.vModelRadio=lq,e.vModelSelect=lW,e.vModelText=lB,e.vShow={name:"show",beforeMount(e,{value:t},{transition:n}){e[ln]="none"===e.style.display?"":e.style.display,n&&t?n.beforeEnter(e):li(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),li(e,!0),r.enter(e)):r.leave(e,()=>{li(e,!1)}):li(e,t))},beforeUnmount(e,{value:t}){li(e,t)}},e.version=iU,e.warn=S,e.watch=function(e,t,n){return t7(e,t,n)},e.watchEffect=function(e,t){return t7(e,null,t)},e.watchPostEffect=function(e,t){return t7(e,null,{flush:"post"})},e.watchSyncEffect=t9,e.withAsyncContext=function(e){let t=iN(),n=e();iE();let r=()=>{iN()!==t&&t.scope.off(),iE()};return P(n)&&(n=n.catch(e=>{throw iA(t),Promise.resolve().then(()=>Promise.resolve().then(r)),e})),[n,()=>{iA(t),Promise.resolve().then(r)}]},e.withCtx=t6,e.withDefaults=function(e,t){return null},e.withDirectives=function(e,t){if(null===t0)return e;let n=iF(t0),r=e.dirs||(e.dirs=[]);for(let e=0;e{let n=e._withKeys||(e._withKeys={}),r=t.join(".");return n[r]||(n[r]=n=>{if(!("key"in n))return;let r=H(n.key);if(t.some(e=>e===r||lZ[e]===r))return e(n)})},e.withMemo=function(e,t,n,r){let i=n[r];if(i&&iB(i,e))return i;let l=t();return l.memo=e.slice(),l.cacheIndex=r,n[r]=l},e.withModifiers=(e,t)=>{if(!e)return e;let n=e._withMods||(e._withMods={}),r=t.join(".");return n[r]||(n[r]=(n,...r)=>{for(let e=0;et6,e}({});
diff --git a/app/tests/__init__.py b/app/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/tests/test_generation_strategy.py b/app/tests/test_generation_strategy.py
new file mode 100644
index 0000000..535c90a
--- /dev/null
+++ b/app/tests/test_generation_strategy.py
@@ -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()
diff --git a/app/tests/test_parse_amount_split.py b/app/tests/test_parse_amount_split.py
new file mode 100644
index 0000000..39e170a
--- /dev/null
+++ b/app/tests/test_parse_amount_split.py
@@ -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()
diff --git a/app/tests/test_services.py b/app/tests/test_services.py
new file mode 100644
index 0000000..3aba629
--- /dev/null
+++ b/app/tests/test_services.py
@@ -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()
diff --git a/app/tests/test_status_alias.py b/app/tests/test_status_alias.py
new file mode 100644
index 0000000..99aa2b2
--- /dev/null
+++ b/app/tests/test_status_alias.py
@@ -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()
diff --git a/app/tests/test_wechat_bot_bridge_skip.py b/app/tests/test_wechat_bot_bridge_skip.py
new file mode 100644
index 0000000..d8fa260
--- /dev/null
+++ b/app/tests/test_wechat_bot_bridge_skip.py
@@ -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()
diff --git a/app/wechat_bot_bridge.py b/app/wechat_bot_bridge.py
new file mode 100644
index 0000000..c14a168
--- /dev/null
+++ b/app/wechat_bot_bridge.py
@@ -0,0 +1,1125 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import json
+import os
+import re
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from collections import deque
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Iterable
+
+
+DEFAULT_WCPP_BASE_URL = os.environ.get("WCPP_BASE_URL", "http://127.0.0.1:18238")
+DEFAULT_XIBAO_BASE_URL = os.environ.get("XIBAO_BASE_URL", "https://zc.workyai.cn")
+DEFAULT_SESSION_FILE = Path(
+ os.environ.get("WCPP_SESSION_FILE", "/root/WeChatPadPro_test_20260227/webui/.session.json")
+)
+BASE_DIR = Path(__file__).resolve().parent
+DEFAULT_STATE_FILE = BASE_DIR / "data" / "wechat_bridge_state.json"
+DEFAULT_META_FILE = BASE_DIR / "data" / "wechat_bridge_meta.json"
+DEFAULT_DAILY_CLEANUP_TIME = os.environ.get("XIBAO_DAILY_CLEANUP_TIME", "00:10")
+RELAY_SERIAL_LINE_RE = re.compile(r"^\s*\d+\s*[、,,..::)\)]\s*")
+
+
+def log(msg: str) -> None:
+ stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ print(f"[{stamp}] {msg}", flush=True)
+
+
+def read_json_file(path: Path, fallback: Any) -> Any:
+ try:
+ if not path.exists():
+ return fallback
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return fallback
+
+
+def write_json_file(path: Path, data: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+ tmp.replace(path)
+
+
+def parse_hhmm(text: str) -> tuple[int, int]:
+ val = str(text or "").strip()
+ m = re.match(r"^(\d{1,2}):(\d{1,2})$", val)
+ if not m:
+ return (0, 10)
+ hour = int(m.group(1))
+ minute = int(m.group(2))
+ if hour < 0 or hour > 23 or minute < 0 or minute > 59:
+ return (0, 10)
+ return (hour, minute)
+
+
+def flatten_wrapped_value(value: Any) -> Any:
+ if isinstance(value, dict):
+ for name in ("str", "string", "String", "value", "Value"):
+ if name in value and value[name] not in (None, ""):
+ return value[name]
+ return value
+
+
+def find_first_value(data: Any, keys: Iterable[str]) -> Any:
+ targets = {k.lower() for k in keys}
+ queue: list[Any] = [data]
+ while queue:
+ current = queue.pop(0)
+ if isinstance(current, dict):
+ for key, val in current.items():
+ if key.lower() in targets:
+ found = flatten_wrapped_value(val)
+ if found not in (None, "", [], {}):
+ return found
+ queue.append(val)
+ elif isinstance(current, list):
+ queue.extend(current)
+ return None
+
+
+def pick_primary_message(item: dict[str, Any]) -> dict[str, Any]:
+ add_msgs = item.get("AddMsgs")
+ if isinstance(add_msgs, list):
+ for msg in add_msgs:
+ if isinstance(msg, dict):
+ return msg
+ return item
+
+
+def summarize_message(item: Any) -> dict[str, Any]:
+ if not isinstance(item, dict):
+ text = str(item)
+ return {
+ "id": "-",
+ "type": "-",
+ "from": "-",
+ "to": "-",
+ "time": "-",
+ "content": text,
+ "raw": item,
+ }
+
+ primary = pick_primary_message(item)
+ msg_id = find_first_value(primary, ["msg_id", "new_msg_id", "MsgId", "NewMsgId", "ClientMsgId", "id"])
+ msg_type = find_first_value(primary, ["msg_type", "MsgType", "ContentType", "Type"])
+ from_user = find_first_value(
+ primary,
+ ["from_user_name", "FromUserName", "FromWxid", "Sender", "Talker", "wxid", "userName"],
+ )
+ to_user = find_first_value(primary, ["to_user_name", "ToUserName", "ToWxid", "Receiver"])
+ create_time = find_first_value(primary, ["create_time", "CreateTime", "Timestamp", "Time", "MsgTime"])
+ content = find_first_value(primary, ["text_content", "TextContent", "content", "Content", "Message", "Text"])
+ if content in (None, "", {}, []):
+ content = find_first_value(primary, ["msg_source", "MsgSource"])
+
+ def _to_text(value: Any) -> str:
+ value = flatten_wrapped_value(value)
+ if isinstance(value, (dict, list)):
+ return json.dumps(value, ensure_ascii=False)
+ if value is None:
+ return "-"
+ return str(value)
+
+ return {
+ "id": _to_text(msg_id),
+ "type": _to_text(msg_type),
+ "from": _to_text(from_user),
+ "to": _to_text(to_user),
+ "time": _to_text(create_time),
+ "content": _to_text(content),
+ "raw": item,
+ }
+
+
+def build_message_dedupe_key(msg: dict[str, Any]) -> str:
+ msg_id = str(msg.get("id") or "-")
+ msg_type = str(msg.get("type") or "-")
+ if msg_id not in {"", "-"}:
+ return f"id:{msg_id}|type:{msg_type}"
+ seed = "|".join(
+ [
+ str(msg.get("from") or "-"),
+ str(msg.get("to") or "-"),
+ str(msg.get("time") or "-"),
+ str(msg.get("content") or "")[:160],
+ ]
+ )
+ digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:20]
+ return f"hash:{digest}|type:{msg_type}"
+
+
+class SeenStore:
+ def __init__(self, path: Path, max_size: int = 8000) -> None:
+ self.path = path
+ self.max_size = max(100, int(max_size))
+ self._queue: deque[str] = deque()
+ self._set: set[str] = set()
+ self._dirty = False
+ self.load()
+
+ def load(self) -> None:
+ raw = read_json_file(self.path, {})
+ seen = raw.get("seen") if isinstance(raw, dict) else []
+ if not isinstance(seen, list):
+ seen = []
+ for item in seen:
+ key = str(item).strip()
+ if not key or key in self._set:
+ continue
+ self._queue.append(key)
+ self._set.add(key)
+ while len(self._queue) > self.max_size:
+ old = self._queue.popleft()
+ self._set.discard(old)
+ self._dirty = False
+
+ def contains(self, key: str) -> bool:
+ return key in self._set
+
+ def add(self, key: str) -> None:
+ if not key or key in self._set:
+ return
+ self._queue.append(key)
+ self._set.add(key)
+ while len(self._queue) > self.max_size:
+ old = self._queue.popleft()
+ self._set.discard(old)
+ self._dirty = True
+
+ def flush(self, force: bool = False) -> None:
+ if not force and not self._dirty:
+ return
+ data = {
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
+ "seen": list(self._queue),
+ }
+ write_json_file(self.path, data)
+ self._dirty = False
+
+
+class WechatXibaoBridge:
+ def __init__(self, args: argparse.Namespace) -> None:
+ self.wechat_base_url = str(args.wechat_base_url).rstrip("/")
+ self.xibao_base_url = str(args.xibao_base_url).rstrip("/")
+ self.auth_key = str(args.wechat_auth_key or "").strip()
+ self.session_file = Path(args.wechat_session_file).resolve()
+ self.sync_count = max(1, int(args.sync_count))
+ self.poll_interval = max(0.5, float(args.poll_interval))
+ self.max_images = max(0, int(args.max_images))
+ self.once = bool(args.once)
+ self.dry_run = bool(args.dry_run)
+ self.allow_from = {x.strip() for x in str(args.allow_from or "").split(",") if x.strip()}
+ self.seen = SeenStore(Path(args.state_file).resolve())
+ self.meta_file = Path(args.meta_file).resolve()
+ self.cleanup_hour, self.cleanup_minute = parse_hhmm(str(args.daily_cleanup_time))
+ self.explicit_auth = bool(self.auth_key)
+ self.self_wxid = ""
+ self.meta = self._load_meta()
+ self.meta_dirty = False
+ self._last_state_flush = time.time()
+ self._last_meta_flush = time.time()
+
+ def _load_meta(self) -> dict[str, Any]:
+ raw = read_json_file(self.meta_file, {})
+ if not isinstance(raw, dict):
+ raw = {}
+
+ users = raw.get("users")
+ if not isinstance(users, dict):
+ users = {}
+ else:
+ normalized_users: dict[str, Any] = {}
+ for wxid, item in users.items():
+ if not isinstance(item, dict):
+ continue
+ line_map = item.get("line_map")
+ if not isinstance(line_map, dict):
+ line_map = {}
+ line_order = item.get("line_order")
+ if not isinstance(line_order, list):
+ line_order = []
+ normalized_users[str(wxid)] = {
+ "updated_at": str(item.get("updated_at", "")),
+ "line_map": {str(k): v for k, v in line_map.items() if isinstance(v, dict)},
+ "line_order": [str(x) for x in line_order if str(x).strip()],
+ }
+ users = normalized_users
+
+ daily_cleanup = raw.get("daily_cleanup")
+ if not isinstance(daily_cleanup, dict):
+ daily_cleanup = {}
+
+ daily_skip = raw.get("daily_skip")
+ if not isinstance(daily_skip, dict):
+ daily_skip = {}
+ else:
+ normalized_daily_skip: dict[str, Any] = {}
+ for wxid, item in daily_skip.items():
+ if not isinstance(item, dict):
+ continue
+ count_raw = item.get("count")
+ try:
+ count_val = int(count_raw or 0)
+ except Exception:
+ count_val = 0
+ if count_val <= 0:
+ continue
+ normalized_daily_skip[str(wxid)] = {
+ "date": str(item.get("date", "")).strip(),
+ "count": max(1, min(count_val, 500)),
+ "updated_at": str(item.get("updated_at", "")),
+ }
+ daily_skip = normalized_daily_skip
+
+ return {
+ "updated_at": str(raw.get("updated_at", "")),
+ "users": users,
+ "daily_cleanup": {
+ "last_date": str(daily_cleanup.get("last_date", "")).strip(),
+ },
+ "daily_skip": daily_skip,
+ }
+
+ def _mark_meta_dirty(self) -> None:
+ self.meta_dirty = True
+ self.meta["updated_at"] = datetime.now().isoformat(timespec="seconds")
+
+ def _flush_meta(self, force: bool = False) -> None:
+ if not force and not self.meta_dirty:
+ return
+ write_json_file(self.meta_file, self.meta)
+ self.meta_dirty = False
+
+ def _flush_state(self, force: bool = False) -> None:
+ self.seen.flush(force=force)
+ self._flush_meta(force=force)
+
+ def _request_json(
+ self,
+ *,
+ base_url: str,
+ path: str,
+ method: str,
+ payload: dict[str, Any] | None = None,
+ key: str | None = None,
+ timeout: int = 30,
+ accept_error: bool = False,
+ ) -> tuple[int, dict[str, Any]]:
+ url = f"{base_url.rstrip('/')}{path}"
+ if key:
+ joiner = "&" if "?" in url else "?"
+ url = f"{url}{joiner}key={urllib.parse.quote(key)}"
+
+ body = None
+ headers: dict[str, str] = {}
+ if payload is not None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+
+ req = urllib.request.Request(url, data=body, method=method.upper(), headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ raw = resp.read().decode("utf-8")
+ data = json.loads(raw) if raw else {}
+ if not isinstance(data, dict):
+ data = {"raw": data}
+ return int(resp.status), data
+ except urllib.error.HTTPError as exc:
+ raw = exc.read().decode("utf-8", errors="replace")
+ try:
+ data = json.loads(raw) if raw else {}
+ except Exception:
+ data = {"ok": False, "error": raw or f"HTTP {exc.code}"}
+ if not isinstance(data, dict):
+ data = {"raw": data}
+ if accept_error:
+ return int(exc.code), data
+ raise RuntimeError(f"HTTP {exc.code}: {raw[:400]}") from exc
+ except urllib.error.URLError as exc:
+ raise RuntimeError(f"请求失败: {exc}") from exc
+
+ def _request_bytes(self, url: str, timeout: int = 45) -> tuple[bytes, str]:
+ req = urllib.request.Request(url, method="GET")
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = resp.read()
+ content_type = resp.headers.get("Content-Type", "application/octet-stream")
+ content_type = content_type.split(";", 1)[0].strip() or "application/octet-stream"
+ return data, content_type
+
+ def _load_auth_from_session(self) -> str:
+ raw = read_json_file(self.session_file, {})
+ if isinstance(raw, dict):
+ key = str(raw.get("authKey", "")).strip()
+ if key:
+ return key
+ return ""
+
+ def is_key_online(self, auth_key: str) -> bool:
+ key = str(auth_key or "").strip()
+ if not key:
+ return False
+ try:
+ _, resp = self._request_json(
+ base_url=self.wechat_base_url,
+ path="/login/GetLoginStatus",
+ method="GET",
+ key=key,
+ timeout=12,
+ accept_error=True,
+ )
+ return int(resp.get("Code") or 0) == 200
+ except Exception:
+ return False
+
+ def refresh_auth_key(self, force: bool = False) -> None:
+ if self.explicit_auth and not force:
+ return
+ latest = self._load_auth_from_session()
+ if not latest:
+ return
+ if latest != self.auth_key:
+ if not self.auth_key:
+ self.auth_key = latest
+ self.self_wxid = ""
+ log(f"已更新 authKey: {self.auth_key}")
+ return
+
+ latest_online = self.is_key_online(latest)
+ current_online = self.is_key_online(self.auth_key)
+ if latest_online or not current_online:
+ self.auth_key = latest
+ self.self_wxid = ""
+ log(f"已更新 authKey: {self.auth_key}")
+ else:
+ log("检测到新的 authKey 但未在线,继续使用当前在线会话。")
+
+ def ensure_self_wxid(self) -> None:
+ if not self.auth_key or self.self_wxid:
+ return
+ try:
+ _, resp = self._request_json(
+ base_url=self.wechat_base_url,
+ path="/user/GetProfile",
+ method="GET",
+ key=self.auth_key,
+ )
+ except Exception as exc:
+ log(f"获取自身 wxid 失败: {exc}")
+ return
+ if resp.get("Code") != 200:
+ return
+ data = resp.get("Data")
+ self_id = find_first_value(data, ["wxid", "Wxid", "userName", "UserName", "to_user_name"])
+ if self_id:
+ self.self_wxid = str(self_id).strip()
+ if self.self_wxid:
+ log(f"已识别当前账号 wxid: {self.self_wxid}")
+
+ def _extract_feedback_records(self, data: dict[str, Any]) -> list[dict[str, Any]]:
+ if not isinstance(data, dict):
+ return []
+ result = data.get("result")
+ if not isinstance(result, dict):
+ return []
+ records = result.get("records")
+ if isinstance(records, list) and records:
+ return [x for x in records if isinstance(x, dict)]
+ records = result.get("new_records")
+ if isinstance(records, list) and records:
+ return [x for x in records if isinstance(x, dict)]
+ return []
+
+ def save_feedback_context(self, from_user: str, data: dict[str, Any]) -> None:
+ from_user = str(from_user or "").strip()
+ if not from_user:
+ return
+ records = self._extract_feedback_records(data)
+ if not records:
+ return
+
+ line_map: dict[str, dict[str, Any]] = {}
+ line_order: list[str] = []
+ fallback_index = 0
+ for item in records:
+ source_line = str(item.get("source_line") or item.get("raw_text") or "").strip()
+ if not source_line:
+ continue
+ line_serial = str(item.get("line_serial") or "").strip()
+ if not line_serial:
+ fallback_index += 1
+ line_serial = str(fallback_index)
+ if line_serial in line_map:
+ continue
+ line_order.append(line_serial)
+ line_map[line_serial] = {
+ "source_line": source_line,
+ "record": {
+ "source_line": source_line,
+ "raw_text": str(item.get("raw_text") or ""),
+ "branch": str(item.get("branch") or ""),
+ "amount": str(item.get("amount") or ""),
+ "type": str(item.get("type") or ""),
+ "page": str(item.get("page") or ""),
+ "status": str(item.get("status") or ""),
+ "line_serial": line_serial,
+ "line_product_index": str(item.get("line_product_index") or ""),
+ "dedup_key": str(item.get("dedup_key") or ""),
+ "signature_key": str(item.get("signature_key") or ""),
+ },
+ }
+
+ if not line_map:
+ return
+
+ users = self.meta.setdefault("users", {})
+ users[from_user] = {
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
+ "line_map": line_map,
+ "line_order": line_order,
+ }
+ if len(users) > 400:
+ keys = sorted(users.keys(), key=lambda k: str(users[k].get("updated_at", "")))
+ for key in keys[:-300]:
+ users.pop(key, None)
+
+ self._mark_meta_dirty()
+
+ def resolve_feedback_item(self, from_user: str, serial: str) -> dict[str, Any] | None:
+ users = self.meta.get("users")
+ if not isinstance(users, dict):
+ return None
+ context = users.get(str(from_user or "").strip())
+ if not isinstance(context, dict):
+ return None
+ line_map = context.get("line_map")
+ if not isinstance(line_map, dict):
+ return None
+ serial_text = str(serial or "").strip()
+ if not serial_text:
+ return None
+
+ item = line_map.get(serial_text)
+ if isinstance(item, dict):
+ return item
+
+ line_order = context.get("line_order")
+ if isinstance(line_order, list) and serial_text.isdigit():
+ idx = int(serial_text) - 1
+ if 0 <= idx < len(line_order):
+ mapped_serial = str(line_order[idx]).strip()
+ item = line_map.get(mapped_serial)
+ if isinstance(item, dict):
+ return item
+ return None
+
+ def parse_feedback_command(self, text: str) -> tuple[str, str] | None:
+ content = str(text or "").strip()
+ if not content:
+ return None
+ m = re.match(r"^/?反馈\s*[+::\-]?\s*(\d{1,4})(?:[\s,,;;::\-]+(.*))?$", content)
+ if not m:
+ return None
+ serial = str(m.group(1)).strip()
+ note = str(m.group(2) or "").strip()
+ return serial, note
+
+ def parse_skip_command(self, text: str) -> int | None:
+ content = str(text or "").strip()
+ if not content:
+ return None
+ m = re.match(r"^/?跳过\s*[((]?\s*(\d{1,4})\s*[))]?\s*$", content)
+ if not m:
+ return None
+ return max(0, min(int(m.group(1)), 500))
+
+ def _today_text(self) -> str:
+ return datetime.now().strftime("%Y-%m-%d")
+
+ def set_daily_skip(self, from_user: str, count: int) -> int:
+ user = str(from_user or "").strip()
+ if not user:
+ return 0
+
+ daily_skip = self.meta.setdefault("daily_skip", {})
+ if not isinstance(daily_skip, dict):
+ daily_skip = {}
+ self.meta["daily_skip"] = daily_skip
+
+ normalized_count = max(0, min(int(count or 0), 500))
+ if normalized_count <= 0:
+ if user in daily_skip:
+ daily_skip.pop(user, None)
+ self._mark_meta_dirty()
+ return 0
+
+ daily_skip[user] = {
+ "date": self._today_text(),
+ "count": normalized_count,
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
+ }
+ if len(daily_skip) > 1000:
+ keys = sorted(daily_skip.keys(), key=lambda k: str(daily_skip[k].get("updated_at", "")))
+ for key in keys[:-800]:
+ daily_skip.pop(key, None)
+
+ self._mark_meta_dirty()
+ return normalized_count
+
+ def get_daily_skip(self, from_user: str) -> int:
+ user = str(from_user or "").strip()
+ if not user:
+ return 0
+ daily_skip = self.meta.get("daily_skip")
+ if not isinstance(daily_skip, dict):
+ return 0
+ item = daily_skip.get(user)
+ if not isinstance(item, dict):
+ return 0
+
+ if str(item.get("date") or "").strip() != self._today_text():
+ daily_skip.pop(user, None)
+ self._mark_meta_dirty()
+ return 0
+
+ try:
+ count = int(item.get("count") or 0)
+ except Exception:
+ count = 0
+ if count <= 0:
+ daily_skip.pop(user, None)
+ self._mark_meta_dirty()
+ return 0
+ return max(1, min(count, 500))
+
+ def apply_daily_skip_to_raw_text(self, from_user: str, raw_text: str) -> tuple[str, int, int]:
+ requested = self.get_daily_skip(from_user)
+ if requested <= 0:
+ return raw_text, 0, 0
+
+ lines = str(raw_text or "").splitlines()
+ if not lines:
+ return raw_text, 0, requested
+
+ removed = 0
+ keep_lines: list[str] = []
+ for line in lines:
+ source = str(line)
+ if removed < requested and RELAY_SERIAL_LINE_RE.match(source.strip()):
+ removed += 1
+ continue
+ keep_lines.append(source)
+
+ normalized = "\n".join(x for x in keep_lines if str(x).strip())
+ if not normalized.strip():
+ normalized = "#接龙"
+ return normalized, removed, requested
+
+ def handle_feedback(self, from_user: str, serial: str, note: str) -> None:
+ item = self.resolve_feedback_item(from_user=from_user, serial=serial)
+ if not item:
+ self.send_text(from_user, "未找到可反馈的序号,请先发送 #接龙 生成后再反馈。")
+ return
+
+ source_line = str(item.get("source_line") or "").strip()
+ record = item.get("record") if isinstance(item.get("record"), dict) else {}
+ note_text = str(note or "").strip() or f"用户反馈序号{serial}"
+ mark_type = "recognition_error" if "识别" in note_text else "generation_error"
+
+ if self.dry_run:
+ log(
+ f"[dry-run] feedback from={from_user} serial={serial} mark_type={mark_type} "
+ f"source_line={source_line} note={note_text}"
+ )
+ self.send_text(from_user, f"反馈已接收(演练模式):序号{serial}")
+ return
+
+ status, resp = self._request_json(
+ base_url=self.xibao_base_url,
+ path="/api/log/mark",
+ method="POST",
+ payload={
+ "mark_type": mark_type,
+ "source_line": source_line,
+ "note": f"{note_text} | from={from_user}",
+ "record": record,
+ },
+ accept_error=True,
+ timeout=40,
+ )
+ if not resp.get("ok"):
+ err = str(resp.get("error") or f"HTTP {status}").strip()
+ self.send_text(from_user, f"反馈记录失败: {err}")
+ return
+
+ issue = resp.get("issue") if isinstance(resp.get("issue"), dict) else {}
+ issue_id = str(issue.get("id") or "").strip()
+ suffix = f",编号 {issue_id}" if issue_id else ""
+ self.send_text(from_user, f"反馈已记录:序号{serial}{suffix}")
+
+ def _clear_history_keep_feedback(self) -> bool:
+ history_path = ""
+ try:
+ _, cfg = self._request_json(
+ base_url=self.xibao_base_url,
+ path="/api/config",
+ method="GET",
+ accept_error=False,
+ timeout=20,
+ )
+ history_path = str(cfg.get("resolved_history_file") or "").strip()
+ except Exception as exc:
+ log(f"读取配置失败(history路径): {exc}")
+
+ if history_path:
+ p = Path(history_path)
+ if p.exists() or p.parent.exists():
+ try:
+ write_json_file(p, [])
+ log(f"已清空历史文件: {p}")
+ return True
+ except Exception as exc:
+ log(f"清空历史文件失败: {exc}")
+
+ try:
+ _, resp = self._request_json(
+ base_url=self.xibao_base_url,
+ path="/api/history/clear",
+ method="POST",
+ payload={},
+ accept_error=True,
+ timeout=40,
+ )
+ if resp.get("ok"):
+ if resp.get("skipped_suppressed_cleared"):
+ log("已清空历史(附带清空了屏蔽列表)。")
+ else:
+ log("已清空历史。")
+ return True
+ log(f"清空历史失败: {resp}")
+ return False
+ except Exception as exc:
+ log(f"清空历史请求异常: {exc}")
+ return False
+
+ def maybe_run_daily_cleanup(self) -> None:
+ now = datetime.now()
+ today = now.strftime("%Y-%m-%d")
+ daily = self.meta.setdefault("daily_cleanup", {})
+ last_date = str(daily.get("last_date") or "").strip()
+ if last_date == today:
+ return
+ if (now.hour, now.minute) < (self.cleanup_hour, self.cleanup_minute):
+ return
+
+ log("开始执行每日清理:图片输出 + 历史记录(保留反馈记录)")
+ output_ok = False
+ history_ok = False
+
+ try:
+ _, resp = self._request_json(
+ base_url=self.xibao_base_url,
+ path="/api/output/clear",
+ method="POST",
+ payload={},
+ accept_error=True,
+ timeout=120,
+ )
+ output_ok = bool(resp.get("ok"))
+ if output_ok:
+ log("已清空输出图片。")
+ else:
+ log(f"清空输出图片失败: {resp}")
+ except Exception as exc:
+ log(f"清空输出图片异常: {exc}")
+
+ history_ok = self._clear_history_keep_feedback()
+
+ if output_ok and history_ok:
+ daily["last_date"] = today
+ self._mark_meta_dirty()
+ log(f"每日清理完成: {today}")
+
+ def send_text(self, to_user: str, text: str) -> None:
+ if self.dry_run:
+ log(f"[dry-run] send text to={to_user}: {text}")
+ return
+ body = {
+ "MsgItem": [
+ {
+ "MsgType": 1,
+ "ToUserName": to_user,
+ "TextContent": text,
+ }
+ ]
+ }
+ _, resp = self._request_json(
+ base_url=self.wechat_base_url,
+ path="/message/SendTextMessage",
+ method="POST",
+ payload=body,
+ key=self.auth_key,
+ timeout=30,
+ )
+ if resp.get("Code") != 200:
+ raise RuntimeError(f"SendTextMessage失败: {json.dumps(resp, ensure_ascii=False)}")
+
+ def send_image_data_uri(self, to_user: str, data_uri: str) -> None:
+ if self.dry_run:
+ log(f"[dry-run] send image to={to_user}, bytes={len(data_uri)}")
+ return
+ body = {
+ "MsgItem": [
+ {
+ "MsgType": 2,
+ "ToUserName": to_user,
+ "ImageContent": data_uri,
+ }
+ ]
+ }
+ _, resp = self._request_json(
+ base_url=self.wechat_base_url,
+ path="/message/SendImageNewMessage",
+ method="POST",
+ payload=body,
+ key=self.auth_key,
+ timeout=60,
+ )
+ if resp.get("Code") != 200:
+ raise RuntimeError(f"SendImageNewMessage失败: {json.dumps(resp, ensure_ascii=False)}")
+
+ def parse_command(self, text: str) -> dict[str, str] | None:
+ content = str(text or "").strip()
+ if not content:
+ return None
+
+ feedback = self.parse_feedback_command(content)
+ if feedback is not None:
+ serial, note = feedback
+ return {"action": "feedback", "serial": serial, "payload": note}
+
+ skip_count = self.parse_skip_command(content)
+ if skip_count is not None:
+ return {"action": "set_skip", "count": str(skip_count), "payload": ""}
+
+ lower = content.lower()
+ if lower in {"/喜报", "喜报", "#喜报", "/喜报 help", "/喜报 帮助", "喜报帮助"}:
+ return {"action": "help", "payload": ""}
+
+ prefixes = ["/喜报", "喜报", "#喜报"]
+ for prefix in prefixes:
+ if content.startswith(prefix):
+ rest = content[len(prefix) :].strip(" ::\n\t")
+ if not rest:
+ return {"action": "help", "payload": ""}
+ rest_lower = rest.lower()
+ if rest_lower in {"help", "帮助"}:
+ return {"action": "help", "payload": ""}
+ if rest.startswith("解析"):
+ payload = rest[2:].strip(" ::\n\t")
+ return {"action": "parse", "payload": payload}
+ if rest.startswith("生成"):
+ payload = rest[2:].strip(" ::\n\t")
+ return {"action": "generate", "payload": payload}
+ return {"action": "generate", "payload": rest}
+
+ if "#接龙" in content:
+ return {"action": "generate", "payload": content}
+
+ return None
+
+ def normalize_raw_text(self, payload: str) -> str:
+ text = str(payload or "").strip()
+ if not text:
+ return ""
+ if "#接龙" in text:
+ return text
+ return f"#接龙\n{text}"
+
+ def format_parse_result(self, data: dict[str, Any]) -> str:
+ result = data.get("result") if isinstance(data, dict) else {}
+ if not isinstance(result, dict):
+ return "解析完成。"
+ summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
+ parsed = int(summary.get("parsed") or 0)
+ new_count = int(summary.get("new") or 0)
+ duplicate = int(summary.get("duplicate") or 0)
+ pending = int(summary.get("insurance_pending") or 0)
+ return (
+ "解析完成\n"
+ f"- 识别条数: {parsed}\n"
+ f"- 可生成: {new_count}\n"
+ f"- 重复: {duplicate}\n"
+ f"- 待选保险年限: {pending}"
+ )
+
+ def format_generate_result(self, data: dict[str, Any]) -> str:
+ result = data.get("result") if isinstance(data, dict) else {}
+ summary = result.get("summary") if isinstance(result, dict) and isinstance(result.get("summary"), dict) else {}
+ generated = int(data.get("generated_count") or 0)
+ duplicate = int(summary.get("duplicate") or 0)
+ pending = int(summary.get("insurance_pending") or 0)
+ if generated <= 0:
+ return (
+ "本次没有可生成的新记录\n"
+ f"- 重复记录: {duplicate}\n"
+ f"- 待选保险年限: {pending}"
+ )
+ return f"生成完成,本次生成 {generated} 张。若有问题请发送:反馈+序号+说明"
+
+ def compose_help_text(self) -> str:
+ return (
+ "喜报机器人用法:\n"
+ "1) /喜报 生成 + 接龙文本\n"
+ "2) /喜报 解析 + 接龙文本\n"
+ "3) 直接发送包含 #接龙 的文本\n"
+ "4) 生成有误时:反馈+序号+说明\n"
+ "5) 发送 跳过3(或 跳过(3)),当天自动跳过前3条;跳过0 取消\n"
+ "示例:\n"
+ "/喜报 生成\\n#接龙\\n1、营江路揽收现金10万存一年\n"
+ "反馈2 网点写错了\n"
+ "跳过2"
+ )
+
+ def handle_command(self, msg: dict[str, Any], command: dict[str, str]) -> None:
+ from_user = str(msg.get("from") or "").strip()
+ if not from_user:
+ return
+
+ action = command.get("action", "")
+ payload = command.get("payload", "")
+ if action == "feedback":
+ serial = str(command.get("serial") or "").strip()
+ if not serial:
+ self.send_text(from_user, "反馈格式错误,请发送:反馈+序号+说明")
+ return
+ self.handle_feedback(from_user=from_user, serial=serial, note=payload)
+ return
+
+ if action == "set_skip":
+ try:
+ count = int(str(command.get("count") or "0").strip() or "0")
+ except Exception:
+ self.send_text(from_user, "跳过格式错误,请发送:跳过3 或 跳过(3)")
+ return
+ effective = self.set_daily_skip(from_user=from_user, count=count)
+ if effective <= 0:
+ self.send_text(from_user, "已取消今日自动跳过。")
+ else:
+ self.send_text(from_user, f"已设置今日自动跳过前 {effective} 条(仅当天生效)。")
+ return
+
+ if action == "help":
+ self.send_text(from_user, self.compose_help_text())
+ return
+
+ raw_text = self.normalize_raw_text(payload)
+ if not raw_text:
+ self.send_text(from_user, "未检测到有效文本,请发送 /喜报 帮助 查看用法。")
+ return
+ raw_text, skipped_count, requested_skip = self.apply_daily_skip_to_raw_text(from_user=from_user, raw_text=raw_text)
+
+ endpoint = "/api/parse" if action == "parse" else "/api/generate"
+ start_hint = "收到,正在解析..." if action == "parse" else "收到,正在生成喜报,请稍候..."
+ if requested_skip > 0:
+ start_hint = f"{start_hint}(已跳过 {skipped_count}/{requested_skip} 条)"
+ self.send_text(from_user, start_hint)
+
+ status, resp = self._request_json(
+ base_url=self.xibao_base_url,
+ path=endpoint,
+ method="POST",
+ payload={"raw_text": raw_text, "save_history": True} if action == "generate" else {"raw_text": raw_text},
+ accept_error=True,
+ timeout=300,
+ )
+
+ if not resp.get("ok"):
+ err = str(resp.get("error") or resp.get("message") or f"HTTP {status}").strip()
+ if resp.get("error_code") == "insurance_year_required":
+ err = "包含保险记录但未指定 3年交/5年交。请在文本里写明年限后重试。"
+ self.send_text(from_user, f"处理失败: {err}")
+ return
+
+ self.save_feedback_context(from_user=from_user, data=resp)
+
+ if action == "parse":
+ self.send_text(from_user, self.format_parse_result(resp))
+ return
+
+ self.send_text(from_user, self.format_generate_result(resp))
+
+ images = resp.get("download_images")
+ if not isinstance(images, list) or not images:
+ return
+
+ send_list = images[: self.max_images] if self.max_images > 0 else []
+ for idx, item in enumerate(send_list, start=1):
+ download_url = str(item.get("download_url") or "").strip()
+ if not download_url:
+ continue
+ full_url = urllib.parse.urljoin(f"{self.xibao_base_url}/", download_url.lstrip("/"))
+ try:
+ raw, content_type = self._request_bytes(full_url, timeout=90)
+ b64 = base64.b64encode(raw).decode("ascii")
+ data_uri = f"data:{content_type};base64,{b64}"
+ self.send_image_data_uri(from_user, data_uri)
+ log(f"已回发图片 {idx}/{len(send_list)} -> {from_user}")
+ except Exception as exc:
+ self.send_text(from_user, f"第{idx}张图片回发失败: {exc}")
+
+ remain = len(images) - len(send_list)
+ if remain > 0:
+ self.send_text(from_user, f"还有 {remain} 张图片未回发(已达到单次上限 {self.max_images})。")
+
+ def should_ignore_message(self, msg: dict[str, Any]) -> bool:
+ msg_type = str(msg.get("type") or "").strip()
+ from_user = str(msg.get("from") or "").strip()
+ to_user = str(msg.get("to") or "").strip()
+ content = str(msg.get("content") or "").strip()
+
+ if msg_type not in {"1", "01"}:
+ return True
+ if not from_user or not to_user or not content:
+ return True
+ if from_user == "filehelper":
+ return True
+ if self.self_wxid and from_user == self.self_wxid:
+ return True
+ if from_user.endswith("@chatroom") or to_user.endswith("@chatroom"):
+ return True
+ if self.allow_from and from_user not in self.allow_from:
+ return True
+ return False
+
+ def handle_polled_message(self, raw: Any) -> None:
+ msg = summarize_message(raw)
+ if self.should_ignore_message(msg):
+ return
+
+ dedupe_key = build_message_dedupe_key(msg)
+ if self.seen.contains(dedupe_key):
+ return
+ self.seen.add(dedupe_key)
+
+ cmd = self.parse_command(str(msg.get("content") or ""))
+ if not cmd:
+ return
+
+ log(
+ f"收到指令 from={msg.get('from')} id={msg.get('id')} type={msg.get('type')} "
+ f"content={str(msg.get('content') or '')[:120]}"
+ )
+ try:
+ self.handle_command(msg, cmd)
+ except Exception as exc:
+ log(f"处理指令失败: {exc}")
+ try:
+ self.send_text(str(msg.get("from") or ""), f"处理失败: {exc}")
+ except Exception:
+ pass
+
+ def poll_once(self) -> None:
+ self.refresh_auth_key(force=False)
+ if not self.auth_key:
+ log(f"未获取到 authKey(session: {self.session_file}),等待中...")
+ return
+ self.ensure_self_wxid()
+
+ _, resp = self._request_json(
+ base_url=self.wechat_base_url,
+ path="/message/HttpSyncMsg",
+ method="POST",
+ payload={"Count": self.sync_count},
+ key=self.auth_key,
+ timeout=30,
+ accept_error=False,
+ )
+
+ code = int(resp.get("Code") or 0)
+ text = str(resp.get("Text") or "").strip()
+ if code != 200:
+ if "重新登录" in text or "不存在" in text:
+ self.refresh_auth_key(force=True)
+ log(f"消息轮询返回 code={code} text={text or '-'}")
+ return
+
+ data = resp.get("Data")
+ if not isinstance(data, list) or not data:
+ return
+
+ for item in data:
+ self.handle_polled_message(item)
+
+ def run(self) -> int:
+ log("微信桥接机器人启动")
+ log(f"WeChat API: {self.wechat_base_url}")
+ log(f"喜报 API: {self.xibao_base_url}")
+ log(f"状态文件: {self.seen.path}")
+ log(f"元数据文件: {self.meta_file}")
+ log(f"每日清理时间: {self.cleanup_hour:02d}:{self.cleanup_minute:02d}")
+ if self.allow_from:
+ log(f"仅处理白名单发送者: {', '.join(sorted(self.allow_from))}")
+
+ try:
+ while True:
+ try:
+ self.maybe_run_daily_cleanup()
+ self.poll_once()
+ except Exception as exc:
+ log(f"轮询异常: {exc}")
+
+ now = time.time()
+ if now - self._last_state_flush >= 6:
+ self.seen.flush(force=False)
+ self._last_state_flush = now
+ if now - self._last_meta_flush >= 6:
+ self._flush_meta(force=False)
+ self._last_meta_flush = now
+
+ if self.once:
+ break
+ time.sleep(self.poll_interval)
+ except KeyboardInterrupt:
+ log("收到中断信号,准备退出")
+ finally:
+ self._flush_state(force=True)
+ return 0
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Xibao <-> WeChatPadPro bridge")
+ parser.add_argument("--wechat-base-url", default=DEFAULT_WCPP_BASE_URL, help="WeChatPadPro API 地址")
+ parser.add_argument("--wechat-auth-key", default=os.environ.get("WCPP_AUTH_KEY", ""), help="WeChat authKey")
+ parser.add_argument("--wechat-session-file", default=str(DEFAULT_SESSION_FILE), help="webui 会话文件路径")
+ parser.add_argument("--xibao-base-url", default=DEFAULT_XIBAO_BASE_URL, help="喜报服务地址")
+ parser.add_argument("--poll-interval", type=float, default=2.0, help="消息轮询间隔(秒)")
+ parser.add_argument("--sync-count", type=int, default=30, help="每次轮询拉取条数")
+ parser.add_argument("--max-images", type=int, default=3, help="每次最多回发图片数量")
+ parser.add_argument("--state-file", default=str(DEFAULT_STATE_FILE), help="消息去重状态文件")
+ parser.add_argument("--meta-file", default=str(DEFAULT_META_FILE), help="反馈上下文与清理状态文件")
+ parser.add_argument("--daily-cleanup-time", default=DEFAULT_DAILY_CLEANUP_TIME, help="每日清理时间 HH:MM")
+ parser.add_argument("--allow-from", default="", help="只处理这些发送者wxid(逗号分隔)")
+ parser.add_argument("--once", action="store_true", help="只执行一次轮询")
+ parser.add_argument("--dry-run", action="store_true", help="仅打印不真正回消息")
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv or sys.argv[1:])
+ bridge = WechatXibaoBridge(args)
+ return bridge.run()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())