实现Agent对话,合格自动提交,不合格补充材料的能力
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
last_reviewed: 2026-06-13
|
||||
---
|
||||
|
||||
# src/web 模块设计说明
|
||||
@@ -11,20 +11,47 @@ last_reviewed: 2026-06-11
|
||||
- **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads/<session_id>/` 目录下,避免并发冲突。
|
||||
- **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。
|
||||
- **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。
|
||||
- **统一文件上传**:2026-06-12 改造,将 PDF 和图片上传入口合并为单一上传区,用户通过一个入口上传所有文件类型。
|
||||
|
||||
## 变更历史
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-06-13 | LLM 流式思考过程展示:后端 SSE 推送 `llm_stream` 事件(start/chunk/end/error),前端聊天气泡实时展示 AI 思考过程 |
|
||||
| 2026-06-12 | 文件进度实时反馈:后端 SSE 推送 `file_progress` 事件(processing/done/cached/error),前端文件消息实时更新状态 + 展示提取摘要 |
|
||||
| 2026-06-12 | 配置交互改为逐项引导:config.json 缺失字段时 AI 逐个提示用户通过聊天输入,全部完成后自动开始处理 |
|
||||
| 2026-06-12 | 聊天窗口精简:移除 SSE 日志流显示,仅保留关键状态消息;config.json 配置不完整时 AI 主动提示缺失字段 |
|
||||
| 2026-06-12 | 移除开始处理按钮,改为自动触发:config.json 解析完成且配置完整(username/password)且有发票文件时自动开始处理 |
|
||||
| 2026-06-12 | 文件上传通知改为逐条消息:每个文件单独一条聊天气泡,上传框 flex 居中、固定高度 |
|
||||
| 2026-06-12 | 文件上传反馈移至聊天窗口:上传/拖拽/同步文件后以聊天气泡通知,上传框固定高度不再显示文件标签 |
|
||||
| 2026-06-12 | 修复聊天窗口消息覆盖问题:将消息区域与输入区域分离,消息区独立滚动,新增用户文本输入功能 |
|
||||
| 2026-06-12 | 合并 PDF/图片上传入口,`/api/files` 返回统一文件列表,前端使用 `allFiles` 单一数组管理 |
|
||||
| 2026-06-12 | 移除配置表单,config.json 通过统一上传入口自动解析,配置存入 `sessionConfig` 对象,前端不再展示配置输入框 |
|
||||
| 2026-06-12 | 暗色日志窗口替换为 AI 聊天风格窗口,SSE 日志以聊天气泡形式展示,支持打字指示器动画 |
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/web/
|
||||
├── app.py # Flask 应用入口,路由、管道编排、日志收集
|
||||
├── sse_handler.py # SSE 日志收集器、日志转义工具
|
||||
├── routes.py # 路由定义、SSE 端点
|
||||
├── templates/
|
||||
│ ├── index.html # PC 端主界面(上传、配置、处理、编辑、提交)
|
||||
│ ├── index.html # PC 端主界面(上传、处理、编辑、提交)
|
||||
│ └── mobile_upload.html # 移动端上传页面(拍照/相册选择)
|
||||
└── static/
|
||||
├── css/
|
||||
│ └── index.css # 全局样式(上传区、日志面板、可编辑表格)
|
||||
└── js/
|
||||
└── index.js # 前端逻辑(上传、SSE 日志、表格编辑、二维码同步)
|
||||
├── index.js # 入口:初始化 App 全局状态、绑定事件
|
||||
├── state.js # 全局状态管理(App 对象)
|
||||
├── chat.js # 聊天气泡渲染、文件消息、LLM 流式气泡
|
||||
├── agent.js # Agent 事件处理、用户输入管理
|
||||
├── process.js # SSE 连接、事件路由、管道启动
|
||||
├── config.js # 配置解析、逐项引导
|
||||
├── upload.js # 文件上传、拖拽处理
|
||||
├── sync.js # 移动端同步
|
||||
└── utils.js # HTML 转义等工具函数
|
||||
```
|
||||
|
||||
## 数据流
|
||||
@@ -64,8 +91,8 @@ graph TD
|
||||
| GET | `/` | 主界面 |
|
||||
| POST | `/api/session` | 创建会话,返回 session_id |
|
||||
| POST | `/api/upload/<sid>` | 上传 PDF/图片 |
|
||||
| GET | `/api/files/<sid>` | 列出会话文件 |
|
||||
| POST | `/api/process/<sid>` | 启动管道(后台线程) |
|
||||
| GET | `/api/files/<sid>` | 列出会话文件(返回统一 `files` 列表,含 `name`、`type`、`size` 字段;旧字段 `pdfs`/`images` 保留向后兼容) |
|
||||
| POST | `/api/agent/process/<sid>` | 启动 Agent 管道(后台线程) |
|
||||
| GET | `/api/logs/<sid>` | SSE 日志流 |
|
||||
| GET | `/api/data/<sid>` | 获取发票数据 JSON |
|
||||
| POST | `/api/save/<sid>` | 保存前端编辑的发票数据 |
|
||||
@@ -77,7 +104,7 @@ graph TD
|
||||
|
||||
### 日志收集
|
||||
|
||||
`_SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
|
||||
`SSELogHandler` 将管道日志写入 `session.log`,SSE 端点通过文件偏移量增量读取,实现前端实时日志展示。日志收集器在管道启动时安装,完成后移除,确保线程安全。
|
||||
|
||||
### 发票类型分流
|
||||
|
||||
@@ -97,8 +124,216 @@ Bot 填报时优先使用 LLM 提取的信息(`travel_info`/`normal_info`)
|
||||
|
||||
### 移动端同步
|
||||
|
||||
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的图片通过 `syncFiles()` 轮询同步到 PC 端内存中的 `imgFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
|
||||
PC 端生成二维码指向 `/mobile/<sid>`,手机端上传的文件通过 `syncFiles()` 轮询同步到 PC 端内存中的 `allFiles` 列表,实现跨设备协作。文件来源标记(`__source`)区分本地选择和服务器同步,避免重复。
|
||||
|
||||
### 配置管理
|
||||
|
||||
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端支持通过上传 `config.json` 快速填充配置表单。
|
||||
配置分两层:项目级 `config.json` 提供默认值,会话级 `uploads/<sid>/config.json` 存储当次会话覆盖值。前端通过统一上传入口接收 `config.json`,自动解析到 `sessionConfig` 对象,不再展示配置输入表单。
|
||||
|
||||
### 聊天气泡消息系统
|
||||
|
||||
聊天窗口的所有消息通过 **事件文件 + SSE 轮询** 机制传输。后端不直接推送消息,而是将事件追加到 session 目录下的日志文件,SSE 端点以 0.5 秒间隔轮询文件增量,再通过 EventSource 推送到前端。
|
||||
|
||||
#### 事件文件总览
|
||||
|
||||
session 目录下有四个事件文件:
|
||||
|
||||
| 文件 | 用途 | 写入方 | 读取方 |
|
||||
|------|------|--------|--------|
|
||||
| `llm_stream.log` | LLM 流式输出(思考过程 + 正式回答) | `llm_extractor` 模块 | SSE 端点 |
|
||||
| `file_events.log` | 文件处理进度 | `extractor` 模块 | SSE 端点 |
|
||||
| `agent_events.log` | Agent 状态变更、请求补充等 | `orchestrator` 模块 | SSE 端点 |
|
||||
| `session.log` | 普通 INFO 日志 | `SSELogHandler` | SSE 端点(当前仅保留,前端已不做处理) |
|
||||
|
||||
#### 后端发送事件
|
||||
|
||||
所有事件文件遵循相同的写入协议:每行一个 JSON 对象,写入后 flush。
|
||||
|
||||
**`llm_stream.log`** — 由 `_emit_llm_stream(source_dir, phase, ...)` 写入:
|
||||
|
||||
```python
|
||||
# 开始 LLM 调用(必需)
|
||||
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
|
||||
|
||||
# 流式文本片段(可选,有内容时发)
|
||||
_emit_llm_stream(source_dir, "chunk", text="让我来分析...")
|
||||
|
||||
# 思考过程片段(可选,模型支持时发)
|
||||
_emit_llm_stream(source_dir, "reasoning", text="根据发票信息...")
|
||||
|
||||
# 调用完成(必需)
|
||||
_emit_llm_stream(source_dir, "end", label="分析完成")
|
||||
|
||||
# 调用失败(异常时发)
|
||||
_emit_llm_stream(source_dir, "error", error="连接超时")
|
||||
```
|
||||
|
||||
**`agent_events.log`** — 由 `_emit_agent_event(session_dir, event_type, ...)` 写入:
|
||||
|
||||
```python
|
||||
# 状态变更
|
||||
_emit_agent_event(session_dir, "agent_state_change", state="extracting", message="正在分析文件...")
|
||||
|
||||
# 请求补充材料
|
||||
_emit_agent_event(session_dir, "agent_request_supplement", message="请上传返程车票...")
|
||||
|
||||
# 信息完整,可以提交
|
||||
_emit_agent_event(session_dir, "agent_ready")
|
||||
|
||||
# 错误
|
||||
_emit_agent_event(session_dir, "agent_error", message="LLM 提取失败")
|
||||
```
|
||||
|
||||
**`file_events.log`** — 由 `_emit_file_event(source_dir, ...)` 写入:
|
||||
|
||||
```python
|
||||
# 文件开始处理
|
||||
_emit_file_event(source_dir, filename, "processing")
|
||||
|
||||
# 文件处理完成(带摘要)
|
||||
_emit_file_event(source_dir, filename, "done", summary={"invoice_number": "...", ...})
|
||||
|
||||
# 使用缓存
|
||||
_emit_file_event(source_dir, filename, "cached")
|
||||
|
||||
# 处理失败
|
||||
_emit_file_event(source_dir, filename, "error", error="PDF 解析失败")
|
||||
```
|
||||
|
||||
**重要约束:`start` 和 `end` 事件不可省略。** 前端 `llmStreamState` 状态机依赖 `start` 创建气泡 DOM,没有 `start` 时后续的 `chunk` 和 `reasoning` 会因守卫条件直接返回。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
|
||||
|
||||
#### SSE 传输层
|
||||
|
||||
`/api/logs/<sid>` 端点(`routes.py`)的轮询逻辑:
|
||||
|
||||
```
|
||||
每 0.5 秒:
|
||||
1. 读取 session.log 增量 → yield "data: <日志行>"
|
||||
2. 读取 file_events.log 增量 → 逐行 yield "data: <JSON>"
|
||||
3. 读取 llm_stream.log 增量 → 逐行 yield "data: <JSON>"
|
||||
4. 读取 agent_events.log 增量 → 逐行 yield "data: <JSON>"
|
||||
5. 检查 result.json 是否存在 → yield "data: {type: 'done'}" 后退出
|
||||
```
|
||||
|
||||
#### 前端事件路由
|
||||
|
||||
`process.js` 的 `EventSource` 监听器按 `msg.type` 分发:
|
||||
|
||||
```
|
||||
msg.type === 'file_progress' → setFileProcessing / setFileDone / setFileCached / setFileError
|
||||
msg.type === 'llm_stream' → handleLLMStream()
|
||||
msg.type 以 'agent_' 开头 → handleAgentEvent()
|
||||
msg.type === 'done' → 关闭 EventSource,展示结果
|
||||
```
|
||||
|
||||
#### 前端聊天气泡渲染
|
||||
|
||||
**LLM 流式气泡**(`chat.js`):
|
||||
|
||||
```
|
||||
start → _createLLMStreamBubble()
|
||||
├─ 创建 <div class="chat-bubble processing llm-stream-bubble">
|
||||
├─ 创建 label 元素(显示 label 文本)
|
||||
├─ 创建 <details> 可折叠区域(思考过程)
|
||||
└─ 创建 textContent 元素(正式回答)
|
||||
└─ 注册到 llmStreamState
|
||||
|
||||
reasoning → _appendLLMStreamReasoning()
|
||||
└─ 追加到 reasoningContent.textContent
|
||||
|
||||
chunk → _appendLLMStreamChunk()
|
||||
└─ 追加到 textContent.textContent
|
||||
|
||||
end → _closeLLMStreamBubble()
|
||||
├─ 气泡 class 从 processing 变为 done
|
||||
└─ 清空 llmStreamState
|
||||
|
||||
error → _errorLLMStreamBubble()
|
||||
├─ 气泡 class 变为 error
|
||||
└─ 清空 llmStreamState
|
||||
```
|
||||
|
||||
**Agent 状态气泡**(`agent.js`):
|
||||
|
||||
```
|
||||
agent_state_change → _handleAgentStateChange()
|
||||
└─ 更新最后一条状态消息(不追加新气泡)
|
||||
|
||||
agent_request_supplement → _handleAgentRequestSupplement()
|
||||
└─ 追加请求补充的气泡
|
||||
|
||||
agent_ready → _handleAgentReady()
|
||||
└─ 追加完成状态气泡
|
||||
|
||||
agent_error → _handleAgentError()
|
||||
└─ 追加错误气泡
|
||||
```
|
||||
|
||||
**文件进度气泡**(`chat.js`):
|
||||
|
||||
```
|
||||
file_progress (processing) → setFileProcessing() → 三点动画
|
||||
file_progress (done) → setFileDone() → 提取摘要
|
||||
file_progress (cached) → setFileCached() → 缓存标识
|
||||
file_progress (error) → setFileError() → 错误信息
|
||||
```
|
||||
|
||||
#### 完整数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Pipe as 后台线程<br/>(管道)
|
||||
participant Files as 事件文件<br/>(session 目录)
|
||||
participant SSE as Flask SSE<br/>(routes.py)
|
||||
participant ES as EventSource<br/>(process.js)
|
||||
participant Chat as chat.js
|
||||
participant Agent as agent.js
|
||||
|
||||
Note over Pipe: 启动管道
|
||||
Pipe->>Files: 追加 llm_stream start
|
||||
Pipe->>Files: 追加 agent_events state_change
|
||||
|
||||
Note over SSE: 0.5s 轮询
|
||||
SSE->>Files: seek(last_size) + read()
|
||||
SSE->>ES: yield "data: start"
|
||||
SSE->>ES: yield "data: state_change"
|
||||
|
||||
ES->>Chat: handleLLMStream({phase:"start"})
|
||||
Chat->>Chat: 创建流式气泡
|
||||
ES->>Agent: handleAgentEvent({type:"agent_state_change"})
|
||||
Agent->>Agent: 显示状态消息
|
||||
|
||||
Note over Pipe: LLM 流式输出
|
||||
Pipe->>Files: 追加 llm_stream reasoning
|
||||
Pipe->>Files: 追加 llm_stream chunk
|
||||
|
||||
SSE->>Files: seek(last_size) + read()
|
||||
SSE->>ES: yield "data: reasoning"
|
||||
SSE->>ES: yield "data: chunk"
|
||||
|
||||
ES->>Chat: handleLLMStream({phase:"reasoning"})
|
||||
Chat->>Chat: 追加思考内容
|
||||
ES->>Chat: handleLLMStream({phase:"chunk"})
|
||||
Chat->>Chat: 追加正式回答
|
||||
|
||||
Note over Pipe: 完成
|
||||
Pipe->>Files: 追加 llm_stream end
|
||||
Pipe->>Files: 写入 result.json
|
||||
|
||||
SSE->>Files: seek(last_size) + read()
|
||||
SSE->>ES: yield "data: end"
|
||||
ES->>Chat: handleLLMStream({phase:"end"})
|
||||
Chat->>Chat: 气泡变完成状态
|
||||
|
||||
SSE->>Files: 检测 result.json
|
||||
SSE->>ES: yield "data: {type:'done'}"
|
||||
ES->>ES: 关闭连接
|
||||
```
|
||||
|
||||
#### 添加新消息类型的步骤
|
||||
|
||||
1. 在对应模块定义 `_emit_xxx()` 函数,写入 session 目录的 JSON 文件
|
||||
2. 在 `routes.py` 的 `stream_logs()` 轮询循环中新增对该文件的轮询
|
||||
3. 在 `process.js` 的 EventSource 监听器中按 `msg.type` 路由到新处理器
|
||||
4. 在 `chat.js` 或 `agent.js` 中实现渲染逻辑
|
||||
5. 确保 `start` 和 `end` 事件成对出现(如果是流式气泡)
|
||||
5
src/web/__init__.py
Normal file
5
src/web/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Web 模块
|
||||
|
||||
提供财务报销系统的 Web 界面功能。
|
||||
"""
|
||||
698
src/web/app.py
698
src/web/app.py
@@ -12,709 +12,39 @@
|
||||
访问: http://localhost:5000
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
# 确保项目根目录在 sys.path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src import get_logger # noqa: E402, I001
|
||||
from src.config import load_config as load_project_config # noqa: E402, I001
|
||||
from src.doc.extractor import ( # noqa: E402, I001
|
||||
extract_invoices,
|
||||
)
|
||||
from src.doc.fill_consumable_doc import ( # noqa: E402, I001
|
||||
CONSUMABLE_DOC_FILENAME,
|
||||
fill_consumable_from_template,
|
||||
)
|
||||
from src.doc.invoice import ( # noqa: E402, I001
|
||||
load_csv,
|
||||
load_invoice_csv,
|
||||
save_csv as save_payment_csv,
|
||||
save_invoice_csv,
|
||||
save_application_json,
|
||||
)
|
||||
from flask import Flask # noqa: E402, I001
|
||||
|
||||
|
||||
fill_log = get_logger("fill_consumable_doc")
|
||||
CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME
|
||||
from src.web import pipeline_web, routes # noqa: E402, I001
|
||||
from src.doc.fill_consumable_doc import CONSUMABLE_DOC_FILENAME # noqa: E402, I001
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
|
||||
UPLOAD_BASE = PROJECT_ROOT / "src" / "web" / "uploads"
|
||||
SESSION_LOG_FILE = "session.log"
|
||||
SESSION_RESULT_FILE = "result.json"
|
||||
INVOICE_GROUPS_FILE = "invoice_groups.json"
|
||||
|
||||
|
||||
def _save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
|
||||
"""保存发票分类结果到 session 目录的 JSON 文件"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
# 只保存各组的数量统计,避免重复存储完整发票数据
|
||||
data = {
|
||||
"travel_count": len(groups.get("travel", [])),
|
||||
"general_count": len(groups.get("general", [])),
|
||||
"application_count": len(groups.get("application", [])),
|
||||
}
|
||||
with open(groups_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
def create_app() -> Flask:
|
||||
"""应用工厂:初始化配置并注册路由"""
|
||||
# 设置出库单模板路径
|
||||
pipeline_web.set_consumable_template(PROJECT_ROOT / CONSUMABLE_DOC_FILENAME)
|
||||
|
||||
# 初始化路由配置
|
||||
routes.init_routes(UPLOAD_BASE)
|
||||
|
||||
def _load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
|
||||
"""从 session 目录加载发票分类统计"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
if not groups_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(groups_path, encoding="utf-8") as f:
|
||||
return cast(dict[str, int] | None, json.load(f))
|
||||
except Exception:
|
||||
return None
|
||||
# 注册 Blueprint
|
||||
app.register_blueprint(routes.web_bp)
|
||||
|
||||
return app
|
||||
|
||||
class _SSELogHandler(logging.Handler):
|
||||
"""将日志写入指定文件(线程安全)"""
|
||||
|
||||
def __init__(self, log_path: Path):
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self._file = open(log_path, "w", encoding="utf-8")
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
msg = self.format(record) + "\n"
|
||||
with self._lock:
|
||||
self._file.write(msg)
|
||||
self._file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close_file(self) -> None:
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
||||
"""安装日志收集器到 app.* 模块"""
|
||||
log_path = session_dir / SESSION_LOG_FILE
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler = _SSELogHandler(log_path)
|
||||
handler.setFormatter(fmt)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _remove_log_collector(handler: _SSELogHandler) -> None:
|
||||
for name in ["extractor", "llm_extractor", "matcher", "pipeline", "bot", "fill_consumable_doc"]:
|
||||
logging.getLogger(name).removeHandler(handler)
|
||||
handler.close_file()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 出库单填写
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_payment_csv(session_dir: Path) -> Path | None:
|
||||
"""查找支付记录 CSV(payment_records.csv)"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_invoice_csv(session_dir: Path) -> Path | None:
|
||||
"""查找发票级别 CSV(invoice_summary.csv)"""
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 出库单填写
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。
|
||||
|
||||
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
|
||||
"""
|
||||
if not CONSUMABLE_TEMPLATE.exists():
|
||||
fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE)
|
||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||
|
||||
# 从统一的分类结果读取,避免重复解析 CSV/JSON
|
||||
groups = _load_invoice_groups(session_dir)
|
||||
if groups is None:
|
||||
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
|
||||
|
||||
if not groups.get("general_count", 0):
|
||||
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
|
||||
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
|
||||
|
||||
csv_path = _resolve_payment_csv(session_dir)
|
||||
if csv_path is None:
|
||||
return {"ok": False, "error": "未找到发票 CSV"}
|
||||
|
||||
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
|
||||
try:
|
||||
fill_log.info("开始填写出库单: %s", out_doc.name)
|
||||
fill_consumable_from_template(csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config)
|
||||
fill_log.info("出库单填写完成")
|
||||
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
|
||||
except ImportError:
|
||||
fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32")
|
||||
return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"}
|
||||
except Exception as e:
|
||||
fill_log.exception("填写出库单失败: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def _append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
result["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
# 差旅发票,跳过出库单生成(不是错误)
|
||||
result["doc_ok"] = None
|
||||
result["doc_skipped"] = True
|
||||
result["doc_message"] = doc_fill.get("error", "")
|
||||
else:
|
||||
result["doc_ok"] = False
|
||||
result["doc_error"] = doc_fill.get("error", "未知错误")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 管道入口
|
||||
# ================================================================
|
||||
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
|
||||
|
||||
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
||||
|
||||
在发票提取和匹配完成后立即判断报销类型:
|
||||
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
|
||||
- 普通发票:无需额外提取(normal_info.json 待实现)
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# ---- Step 1: 发票提取 ----
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
if not invoices:
|
||||
return {"ok": False, "error": "未提取到任何发票数据"}
|
||||
|
||||
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
# 出差申请单单独保存
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
# 保存分类结果(供后续步骤统一读取)
|
||||
_save_invoice_groups(session_dir, groups)
|
||||
|
||||
# ---- Step 2: 差旅/普通信息提取 ----
|
||||
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
|
||||
if is_travel:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("travel_info"):
|
||||
fill_log.info("开始提取差旅信息...")
|
||||
travel_info = extract_travel_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(travel_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("差旅信息已保存到缓存")
|
||||
else:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("normal_info"):
|
||||
fill_log.info("开始提取普通发票信息...")
|
||||
normal_info = extract_normal_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(normal_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("普通发票信息已保存到缓存")
|
||||
|
||||
# 统计发票总数
|
||||
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": invoice_count,
|
||||
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
||||
# 发票类型统计
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
_append_doc_download(result, session_dir.name, doc_fill)
|
||||
return result
|
||||
|
||||
|
||||
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""执行财务系统填报(从前端确认后调用)
|
||||
|
||||
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
|
||||
- 纯差旅发票:差旅报销模式(TODO)
|
||||
- 含普通发票:普通报销模式
|
||||
"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
||||
|
||||
from src.bot import run_bot_web
|
||||
|
||||
# 从统一的分类结果读取,避免重复解析
|
||||
groups = _load_invoice_groups(session_dir)
|
||||
if groups:
|
||||
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
|
||||
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
# TODO: 差旅报销填报流程
|
||||
else:
|
||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||
|
||||
run_bot_web(config, session_dir)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Flask 路由
|
||||
# ================================================================
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index() -> Any:
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/session", methods=["POST"])
|
||||
def create_session() -> Any:
|
||||
"""创建上传会话,返回 session_id"""
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
session_dir = UPLOAD_BASE / sid
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
return jsonify({"session_id": sid})
|
||||
|
||||
|
||||
@app.route("/api/upload/<session_id>", methods=["POST"])
|
||||
def upload_file(session_id: str) -> Any:
|
||||
"""上传 PDF 或图片"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "未选择文件"}), 400
|
||||
|
||||
safe_name = Path(f.filename).name
|
||||
f.save(str(session_dir / safe_name))
|
||||
return jsonify({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@app.route("/api/files/<session_id>", methods=["GET"])
|
||||
def list_files(session_id: str) -> Any:
|
||||
"""列出会话目录中的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
pdfs = sorted(f.name for f in session_dir.glob("*.pdf"))
|
||||
imgs = sorted(f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} for f in session_dir.glob(f"*{ext}"))
|
||||
return jsonify({"pdfs": pdfs, "images": imgs})
|
||||
|
||||
|
||||
@app.route("/api/process/<session_id>", methods=["POST"])
|
||||
def start_process(session_id: str) -> Any:
|
||||
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
# 读取配置
|
||||
config = _build_web_config(body)
|
||||
|
||||
# 写入配置到会话目录
|
||||
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
# 在后台线程执行
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
result = run_pipeline_web(session_dir, config)
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
_remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@app.route("/api/logs/<session_id>")
|
||||
def stream_logs(session_id: str) -> Any:
|
||||
"""SSE 日志流"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
def generate() -> Any:
|
||||
# 先发送已有日志
|
||||
log_file = session_dir / SESSION_LOG_FILE
|
||||
last_size = 0
|
||||
start_time = time.time()
|
||||
timeout = 600 # 10 分钟超时
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if log_file.exists():
|
||||
current_size = log_file.stat().st_size
|
||||
if current_size > last_size:
|
||||
with open(log_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_size)
|
||||
chunk = f.read()
|
||||
if chunk:
|
||||
yield f"data: {_escape_sse(chunk)}\n\n"
|
||||
last_size = current_size
|
||||
|
||||
# 也通过队列发送实时日志
|
||||
# 检查是否完成
|
||||
result_file = session_dir / SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
with open(result_file, encoding="utf-8") as f:
|
||||
result = json.load(f)
|
||||
yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
|
||||
break
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/download/<session_id>/<filename>")
|
||||
def download_file(session_id: str, filename: str) -> Any:
|
||||
"""下载生成的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 防止路径穿越
|
||||
safe_name = Path(filename).name
|
||||
filepath = session_dir / safe_name
|
||||
if not filepath.exists():
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
if safe_name.endswith(".doc"):
|
||||
mimetype = "application/msword"
|
||||
elif safe_name.endswith(".csv"):
|
||||
mimetype = "text/csv; charset=utf-8"
|
||||
else:
|
||||
mimetype = "application/octet-stream"
|
||||
|
||||
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
|
||||
return Response(
|
||||
filepath.read_bytes(),
|
||||
mimetype=mimetype,
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/config/<session_id>", methods=["GET"])
|
||||
def get_session_config(session_id: str) -> Any:
|
||||
"""获取当前会话的配置(供前端回填表单)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
# 只返回前端需要的字段
|
||||
return jsonify(
|
||||
{
|
||||
"username": config.get("username", ""),
|
||||
"password": "", # 不返回密码
|
||||
"default_name": config.get("default_name", ""),
|
||||
"default_card_no": config.get("default_card_no", ""),
|
||||
"default_person_id": config.get("default_person_id", ""),
|
||||
"consumable_storage": config.get("consumable_storage", ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/data/<session_id>", methods=["GET"])
|
||||
def get_invoice_data(session_id: str) -> Any:
|
||||
"""读取发票数据并返回 JSON(供前端表格编辑)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 优先读取支付记录 CSV
|
||||
payment_csv = session_dir / "payment_records.csv"
|
||||
if payment_csv.exists():
|
||||
rows = load_csv(payment_csv)
|
||||
if rows is not None:
|
||||
data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry: dict[str, Any] = dict(row)
|
||||
entry["__row"] = i
|
||||
data.append(entry)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
|
||||
|
||||
# 回退到发票级别 CSV
|
||||
invoice_csv = session_dir / "invoice_summary.csv"
|
||||
if invoice_csv.exists():
|
||||
rows = load_invoice_csv(invoice_csv)
|
||||
if rows is not None:
|
||||
invoice_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry2: dict[str, Any] = dict(row)
|
||||
entry2["__row"] = i
|
||||
invoice_data.append(entry2)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
|
||||
|
||||
# 最后尝试任意 CSV
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE]
|
||||
if csv_files:
|
||||
csv_path = csv_files[0]
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
rows = load_invoice_csv(csv_path)
|
||||
if rows is not None:
|
||||
fallback_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry3: dict[str, Any] = dict(row)
|
||||
entry3["__row"] = i
|
||||
fallback_data.append(entry3)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
|
||||
|
||||
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
||||
|
||||
|
||||
@app.route("/api/save/<session_id>", methods=["POST"])
|
||||
def save_invoice_data(session_id: str) -> Any:
|
||||
"""保存前端编辑后的发票数据到 CSV"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
data = body.get("data", [])
|
||||
csv_filename = body.get("csv_filename", "invoice_summary.csv")
|
||||
|
||||
csv_path = session_dir / csv_filename
|
||||
if not csv_path.exists():
|
||||
return jsonify({"error": "CSV 文件不存在"}), 404
|
||||
|
||||
# 读取原 CSV 获取字段顺序(使用第一个数据的 keys)
|
||||
original_rows = load_csv(csv_path)
|
||||
if original_rows is None or len(original_rows) == 0:
|
||||
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
|
||||
|
||||
# 从原数据中获取字段顺序(去掉内部字段)
|
||||
fieldnames = list(original_rows[0].keys())
|
||||
|
||||
import csv as csv_module
|
||||
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for entry in data:
|
||||
row = {k: entry.get(k, "") for k in fieldnames}
|
||||
writer.writerow(row)
|
||||
|
||||
resp: dict[str, str | bool | None] = {"ok": True}
|
||||
config = _load_session_config(session_dir)
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
resp["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
resp["doc_ok"] = None
|
||||
resp["doc_skipped"] = True
|
||||
else:
|
||||
resp["doc_ok"] = False
|
||||
resp["doc_error"] = doc_fill.get("error") or ""
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@app.route("/api/submit-financial/<session_id>", methods=["POST"])
|
||||
def submit_financial(session_id: str) -> Any:
|
||||
"""手动触发财务系统填报"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 读取配置
|
||||
config_path = session_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 清除上次处理留下的结果文件,避免 SSE 误判为已完成
|
||||
result_file = session_dir / SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
result_file.unlink()
|
||||
|
||||
# 在后台线程执行提交
|
||||
handler = _install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
submit_result = run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {"ok": True}
|
||||
else:
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
_remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 辅助函数
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return jsonify({"error": "会话不存在"}), 404
|
||||
return session_dir
|
||||
|
||||
|
||||
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从请求体构建配置"""
|
||||
config = load_project_config()
|
||||
for key in (
|
||||
"username",
|
||||
"password",
|
||||
"default_name",
|
||||
"default_card_no",
|
||||
"default_person_id",
|
||||
"consumable_storage",
|
||||
):
|
||||
if body.get(key):
|
||||
config[key] = body[key]
|
||||
return config
|
||||
|
||||
|
||||
def _escape_sse(text: str) -> str:
|
||||
"""SSE 数据转义,同时处理 Windows 行尾 \\r\\n"""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
|
||||
|
||||
|
||||
@app.route("/mobile/<session_id>")
|
||||
def mobile_upload(session_id: str) -> Any:
|
||||
"""移动端上传页面"""
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return render_template("mobile_upload.html", error="会话不存在"), 404
|
||||
return render_template("mobile_upload.html", session_id=session_id)
|
||||
|
||||
|
||||
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
||||
def mobile_upload_file(session_id: str) -> Any:
|
||||
"""移动端上传图片(复用 PC 上传逻辑)"""
|
||||
return upload_file(session_id)
|
||||
|
||||
# 直接使用 app 实例(保持向后兼容)
|
||||
create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
259
src/web/pipeline_web.py
Normal file
259
src/web/pipeline_web.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Web 管道逻辑
|
||||
|
||||
负责:
|
||||
- 发票提取管道编排
|
||||
- 财务系统填报触发
|
||||
- 易耗品出库单生成
|
||||
- 发票分类数据持久化
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
# 延迟导入,避免循环引用
|
||||
from src import get_logger # noqa: F401
|
||||
from src.config import load_config as load_project_config
|
||||
from src.doc.fill_consumable_doc import (
|
||||
CONSUMABLE_DOC_FILENAME,
|
||||
fill_consumable_from_template,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
fill_log = get_logger("fill_consumable_doc")
|
||||
|
||||
# 文件常量
|
||||
SESSION_RESULT_FILE = "result.json"
|
||||
INVOICE_GROUPS_FILE = "invoice_groups.json"
|
||||
|
||||
|
||||
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
|
||||
"""保存发票分类结果到 session 目录的 JSON 文件"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
data = {
|
||||
"travel_count": len(groups.get("travel", [])),
|
||||
"general_count": len(groups.get("general", [])),
|
||||
"application_count": len(groups.get("application", [])),
|
||||
}
|
||||
with open(groups_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
|
||||
"""从 session 目录加载发票分类统计"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
if not groups_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(groups_path, encoding="utf-8") as f:
|
||||
return cast(dict[str, int] | None, json.load(f))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||
"""加载会话配置,合并项目全局配置与会话级配置"""
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
return config
|
||||
|
||||
|
||||
def resolve_payment_csv(session_dir: Path) -> Path | None:
|
||||
"""查找支付记录 CSV(payment_records.csv)"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def resolve_invoice_csv(session_dir: Path) -> Path | None:
|
||||
"""查找发票级别 CSV(invoice_summary.csv)"""
|
||||
csv_path = session_dir / "invoice_summary.csv"
|
||||
if csv_path.exists():
|
||||
return csv_path
|
||||
for f in session_dir.glob("*.csv"):
|
||||
if f.name != SESSION_RESULT_FILE:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 出库单填写
|
||||
# ================================================================
|
||||
|
||||
# 在模块加载时确定模板路径(由 app.py 传入 PROJECT_ROOT)
|
||||
_consumable_template: Path | None = None
|
||||
|
||||
|
||||
def set_consumable_template(template_path: Path) -> None:
|
||||
"""设置出库单模板路径(由 app.py 在启动时调用)"""
|
||||
global _consumable_template
|
||||
_consumable_template = template_path
|
||||
|
||||
|
||||
def _try_fill_consumable_doc(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""根据 CSV 填写易耗品出库单,供会话目录下载。
|
||||
|
||||
从 invoice_groups.json 读取分类结果,仅当存在普通发票时才生成出库单。
|
||||
"""
|
||||
template = _consumable_template
|
||||
if template is None or not template.exists():
|
||||
fill_log.warning("出库单模板不存在: %s", template)
|
||||
return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"}
|
||||
|
||||
groups = load_invoice_groups(session_dir)
|
||||
if groups is None:
|
||||
return {"ok": False, "error": "未找到发票分类数据,请先处理"}
|
||||
|
||||
if not groups.get("general_count", 0):
|
||||
fill_log.info("纯差旅发票,跳过易耗品出库单生成")
|
||||
return {"ok": False, "skipped": True, "error": "差旅发票无需生成易耗品出库单"}
|
||||
|
||||
csv_path = resolve_payment_csv(session_dir)
|
||||
if csv_path is None:
|
||||
return {"ok": False, "error": "未找到发票 CSV"}
|
||||
|
||||
out_doc = session_dir / CONSUMABLE_DOC_FILENAME
|
||||
try:
|
||||
fill_log.info("开始填写出库单: %s", out_doc.name)
|
||||
fill_consumable_from_template(csv_path, template, out_doc, config=config)
|
||||
fill_log.info("出库单填写完成")
|
||||
return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME}
|
||||
except ImportError:
|
||||
fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32")
|
||||
return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"}
|
||||
except Exception as e:
|
||||
fill_log.exception("填写出库单失败: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[str, Any]) -> None:
|
||||
"""将出库单下载信息追加到结果字典"""
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
result["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
result["doc_ok"] = None
|
||||
result["doc_skipped"] = True
|
||||
result["doc_message"] = doc_fill.get("error", "")
|
||||
else:
|
||||
result["doc_ok"] = False
|
||||
result["doc_error"] = doc_fill.get("error", "未知错误")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 管道入口
|
||||
# ================================================================
|
||||
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
|
||||
|
||||
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
||||
|
||||
在发票提取和匹配完成后立即判断报销类型:
|
||||
- 差旅发票:调用 LLM 提取差旅信息并缓存到 travel_info.json
|
||||
- 普通发票:无需额外提取(normal_info.json 待实现)
|
||||
"""
|
||||
from src.doc.extractor import extract_invoices
|
||||
|
||||
start = time.time()
|
||||
|
||||
# ---- Step 1: 发票提取 ----
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
if not invoices:
|
||||
return {"ok": False, "error": "未提取到任何发票数据"}
|
||||
|
||||
# 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考)
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
# 出差申请单单独保存
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
# 保存分类结果(供后续步骤统一读取)
|
||||
save_invoice_groups(session_dir, groups)
|
||||
|
||||
# ---- Step 2: 差旅/普通信息提取 ----
|
||||
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
|
||||
if is_travel:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
|
||||
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("travel_info"):
|
||||
fill_log.info("开始提取差旅信息...")
|
||||
travel_info = extract_travel_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(travel_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("差旅信息已保存到缓存")
|
||||
else:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
|
||||
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("normal_info"):
|
||||
fill_log.info("开始提取普通发票信息...")
|
||||
normal_info = extract_normal_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(normal_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("普通发票信息已保存到缓存")
|
||||
|
||||
# 统计发票总数
|
||||
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = {
|
||||
"ok": True,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"invoice_count": invoice_count,
|
||||
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
append_doc_download(result, session_dir.name, doc_fill)
|
||||
return result
|
||||
|
||||
|
||||
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""执行财务系统填报(从前端确认后调用)
|
||||
|
||||
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
|
||||
- 纯差旅发票:差旅报销模式(TODO)
|
||||
- 含普通发票:普通报销模式
|
||||
"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
if not csv_path.exists():
|
||||
return {"ok": False, "error": "未找到发票数据,请先处理"}
|
||||
|
||||
from src.bot import run_bot_web
|
||||
|
||||
groups = load_invoice_groups(session_dir)
|
||||
if groups:
|
||||
if groups.get("travel_count", 0) and not groups.get("general_count", 0):
|
||||
fill_log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
else:
|
||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||
|
||||
run_bot_web(config, session_dir)
|
||||
return {"ok": True}
|
||||
917
src/web/routes.py
Normal file
917
src/web/routes.py
Normal file
@@ -0,0 +1,917 @@
|
||||
"""
|
||||
Flask 路由定义
|
||||
|
||||
所有 Web 端点的路由注册,不包含业务逻辑(业务逻辑在 pipeline_web 和 sse_handler 中)。
|
||||
使用 Blueprint 模式,支持延迟注册到 Flask app。
|
||||
"""
|
||||
|
||||
import csv as csv_module
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Blueprint, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
from src.config import load_config as load_project_config
|
||||
from src.doc.invoice import load_csv, load_invoice_csv
|
||||
|
||||
from . import pipeline_web, sse_handler
|
||||
|
||||
# 在模块加载时确定(由 init_routes 传入)
|
||||
_UPLOAD_BASE: Path | None = None
|
||||
|
||||
web_bp = Blueprint("web", __name__)
|
||||
|
||||
|
||||
def init_routes(upload_base: Path) -> None:
|
||||
"""初始化路由配置,传入上传目录"""
|
||||
global _UPLOAD_BASE
|
||||
_UPLOAD_BASE = upload_base
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 辅助函数
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _validate_session(session_id: str) -> Path | tuple[Response, int]:
|
||||
"""验证会话 ID 并返回会话目录"""
|
||||
if _UPLOAD_BASE is None:
|
||||
return jsonify({"error": "服务未初始化"}), 500
|
||||
session_dir = _UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return jsonify({"error": "会话不存在"}), 404
|
||||
return session_dir
|
||||
|
||||
|
||||
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从请求体构建配置"""
|
||||
config = load_project_config()
|
||||
for key in (
|
||||
"username",
|
||||
"password",
|
||||
"default_name",
|
||||
"default_card_no",
|
||||
"default_person_id",
|
||||
"consumable_storage",
|
||||
):
|
||||
if body.get(key):
|
||||
config[key] = body[key]
|
||||
return config
|
||||
|
||||
|
||||
def _emit_ready_and_submit(
|
||||
session_dir: Path,
|
||||
agent_session: Any,
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Agent 校验通过后,直接触发财务提交(不依赖前端)"""
|
||||
from src.agent import _emit_agent_event
|
||||
|
||||
# 发射 agent_ready 事件,前端 SSE 会收到
|
||||
_emit_agent_event(
|
||||
session_dir,
|
||||
"agent_ready",
|
||||
round=agent_session.rounds,
|
||||
message="信息完整,可以提交",
|
||||
)
|
||||
|
||||
# 直接执行财务提交
|
||||
try:
|
||||
submit_result = pipeline_web.run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": True,
|
||||
"submit_ok": True,
|
||||
"round": agent_session.rounds,
|
||||
"message": "信息完整,已自动提交到财务系统",
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": True,
|
||||
"submit_ok": False,
|
||||
"submit_error": submit_result.get("error"),
|
||||
"round": agent_session.rounds,
|
||||
"message": "校验通过但提交失败",
|
||||
}
|
||||
except Exception as e:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": True,
|
||||
"submit_ok": False,
|
||||
"submit_error": str(e),
|
||||
"round": agent_session.rounds,
|
||||
"message": f"校验通过但提交异常: {e}",
|
||||
}
|
||||
|
||||
# 写入 result 文件,SSE done 事件会读取
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 页面路由
|
||||
# ================================================================
|
||||
|
||||
|
||||
@web_bp.route("/")
|
||||
def index() -> Any:
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@web_bp.route("/mobile/<session_id>")
|
||||
def mobile_upload(session_id: str) -> Any:
|
||||
"""移动端上传页面"""
|
||||
if _UPLOAD_BASE is None:
|
||||
return render_template("mobile_upload.html", error="服务未初始化"), 500
|
||||
session_dir = _UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return render_template("mobile_upload.html", error="会话不存在"), 404
|
||||
return render_template("mobile_upload.html", session_id=session_id)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 会话管理
|
||||
# ================================================================
|
||||
|
||||
|
||||
@web_bp.route("/api/session", methods=["POST"])
|
||||
def create_session() -> Any:
|
||||
"""创建上传会话,返回 session_id"""
|
||||
if _UPLOAD_BASE is None:
|
||||
return jsonify({"error": "服务未初始化"}), 500
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
session_dir = _UPLOAD_BASE / sid
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
return jsonify({"session_id": sid})
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 文件上传与下载
|
||||
# ================================================================
|
||||
|
||||
|
||||
@web_bp.route("/api/upload/<session_id>", methods=["POST"])
|
||||
def upload_file(session_id: str) -> Any:
|
||||
"""上传 PDF 或图片"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "未选择文件"}), 400
|
||||
|
||||
safe_name = Path(f.filename).name
|
||||
f.save(str(session_dir / safe_name))
|
||||
return jsonify({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@web_bp.route("/api/files/<session_id>", methods=["GET"])
|
||||
def list_files(session_id: str) -> Any:
|
||||
"""列出会话目录中的文件(统一列表)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
pdf_exts = {".pdf"}
|
||||
img_exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
|
||||
files = []
|
||||
for f in sorted(session_dir.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
ext = f.suffix.lower()
|
||||
if ext in pdf_exts:
|
||||
files.append({"name": f.name, "type": "pdf", "size": f.stat().st_size})
|
||||
elif ext in img_exts:
|
||||
files.append({"name": f.name, "type": "image", "size": f.stat().st_size})
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"files": files,
|
||||
"pdfs": [item["name"] for item in files if item["type"] == "pdf"],
|
||||
"images": [item["name"] for item in files if item["type"] == "image"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/api/download/<session_id>/<filename>")
|
||||
def download_file(session_id: str, filename: str) -> Any:
|
||||
"""下载生成的文件"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
safe_name = Path(filename).name
|
||||
filepath = session_dir / safe_name
|
||||
if not filepath.exists():
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
if safe_name.endswith(".doc"):
|
||||
mimetype = "application/msword"
|
||||
elif safe_name.endswith(".csv"):
|
||||
mimetype = "text/csv; charset=utf-8"
|
||||
else:
|
||||
mimetype = "application/octet-stream"
|
||||
|
||||
disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}"
|
||||
return Response(
|
||||
filepath.read_bytes(),
|
||||
mimetype=mimetype,
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
||||
def mobile_upload_file(session_id: str) -> Any:
|
||||
"""移动端上传图片(复用 PC 上传逻辑)"""
|
||||
return upload_file(session_id)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 配置
|
||||
# ================================================================
|
||||
|
||||
|
||||
@web_bp.route("/api/config/<session_id>", methods=["GET"])
|
||||
def get_session_config(session_id: str) -> Any:
|
||||
"""获取当前会话的配置(供前端回填表单)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
return jsonify(
|
||||
{
|
||||
"username": config.get("username", ""),
|
||||
"password": "",
|
||||
"default_name": config.get("default_name", ""),
|
||||
"default_card_no": config.get("default_card_no", ""),
|
||||
"default_person_id": config.get("default_person_id", ""),
|
||||
"consumable_storage": config.get("consumable_storage", ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 发票数据处理
|
||||
# ================================================================
|
||||
|
||||
|
||||
@web_bp.route("/api/data/<session_id>", methods=["GET"])
|
||||
def get_invoice_data(session_id: str) -> Any:
|
||||
"""读取发票数据并返回 JSON(供前端表格编辑)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
# 优先读取支付记录 CSV
|
||||
payment_csv = session_dir / "payment_records.csv"
|
||||
if payment_csv.exists():
|
||||
rows = load_csv(payment_csv)
|
||||
if rows is not None:
|
||||
data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry: dict[str, Any] = dict(row)
|
||||
entry["__row"] = i
|
||||
data.append(entry)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": payment_csv.name, "fields": fields, "data": data})
|
||||
|
||||
# 回退到发票级别 CSV
|
||||
invoice_csv = session_dir / "invoice_summary.csv"
|
||||
if invoice_csv.exists():
|
||||
rows = load_invoice_csv(invoice_csv)
|
||||
if rows is not None:
|
||||
invoice_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry2: dict[str, Any] = dict(row)
|
||||
entry2["__row"] = i
|
||||
invoice_data.append(entry2)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": invoice_csv.name, "fields": fields, "data": invoice_data})
|
||||
|
||||
# 最后尝试任意 CSV
|
||||
csv_files = list(session_dir.glob("*.csv"))
|
||||
csv_files = [f for f in csv_files if f.name != pipeline_web.SESSION_RESULT_FILE]
|
||||
if csv_files:
|
||||
csv_path = csv_files[0]
|
||||
rows = load_csv(csv_path)
|
||||
if rows is None:
|
||||
rows = load_invoice_csv(csv_path)
|
||||
if rows is not None:
|
||||
fallback_data: list[dict[str, Any]] = []
|
||||
for i, row in enumerate(rows):
|
||||
entry3: dict[str, Any] = dict(row)
|
||||
entry3["__row"] = i
|
||||
fallback_data.append(entry3)
|
||||
fields = [k for k in rows[0].keys() if not k.startswith("__")] if rows else []
|
||||
return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": fallback_data})
|
||||
|
||||
return jsonify({"error": "未找到发票数据,请先处理"}), 404
|
||||
|
||||
|
||||
@web_bp.route("/api/save/<session_id>", methods=["POST"])
|
||||
def save_invoice_data(session_id: str) -> Any:
|
||||
"""保存前端编辑后的发票数据到 CSV"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
data = body.get("data", [])
|
||||
csv_filename = body.get("csv_filename", "invoice_summary.csv")
|
||||
|
||||
csv_path = session_dir / csv_filename
|
||||
if not csv_path.exists():
|
||||
return jsonify({"error": "CSV 文件不存在"}), 404
|
||||
|
||||
original_rows = load_csv(csv_path)
|
||||
if original_rows is None or len(original_rows) == 0:
|
||||
return jsonify({"error": "无法读取原始 CSV 结构"}), 500
|
||||
|
||||
fieldnames = list(original_rows[0].keys())
|
||||
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for entry in data:
|
||||
row = {k: entry.get(k, "") for k in fieldnames}
|
||||
writer.writerow(row)
|
||||
|
||||
resp: dict[str, str | bool | None] = {"ok": True}
|
||||
config = pipeline_web.load_session_config(session_dir)
|
||||
doc_fill = pipeline_web._try_fill_consumable_doc(session_dir, config)
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}"
|
||||
resp["doc_ok"] = True
|
||||
elif doc_fill.get("skipped"):
|
||||
resp["doc_ok"] = None
|
||||
resp["doc_skipped"] = True
|
||||
else:
|
||||
resp["doc_ok"] = False
|
||||
resp["doc_error"] = doc_fill.get("error") or ""
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 管道处理
|
||||
# ================================================================
|
||||
|
||||
|
||||
@web_bp.route("/api/process/<session_id>", methods=["POST"])
|
||||
def start_process(session_id: str) -> Any:
|
||||
"""启动管道处理(仅发票提取,不自动提交财务系统)"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
config = _build_web_config(body)
|
||||
|
||||
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
result = pipeline_web.run_pipeline_web(session_dir, config)
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@web_bp.route("/api/logs/<session_id>")
|
||||
def stream_logs(session_id: str) -> Any:
|
||||
"""SSE 日志流"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
def generate() -> Any:
|
||||
log_file = session_dir / sse_handler.SESSION_LOG_FILE
|
||||
last_size = 0
|
||||
file_events_file = session_dir / "file_events.log"
|
||||
last_events_size = 0
|
||||
last_stream_size = 0
|
||||
state = {"agent_size": 0}
|
||||
|
||||
start_time = time.time()
|
||||
timeout = 600
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# 轮询普通日志
|
||||
if log_file.exists():
|
||||
current_size = log_file.stat().st_size
|
||||
if current_size > last_size:
|
||||
with open(log_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_size)
|
||||
chunk = f.read()
|
||||
if chunk:
|
||||
yield f"data: {sse_handler.escape_sse(chunk)}\n\n"
|
||||
last_size = current_size
|
||||
|
||||
# 轮询文件进度事件
|
||||
if file_events_file.exists():
|
||||
events_size = file_events_file.stat().st_size
|
||||
if events_size > last_events_size:
|
||||
with open(file_events_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_events_size)
|
||||
new_events = f.read()
|
||||
if new_events:
|
||||
for line in new_events.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
yield f"data: {line}\n\n"
|
||||
last_events_size = events_size
|
||||
|
||||
# 轮询 LLM 流式事件
|
||||
llm_stream_file = session_dir / "llm_stream.log"
|
||||
if llm_stream_file.exists():
|
||||
stream_size = llm_stream_file.stat().st_size
|
||||
if stream_size > last_stream_size:
|
||||
with open(llm_stream_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_stream_size)
|
||||
new_chunks = f.read()
|
||||
if new_chunks:
|
||||
for line in new_chunks.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
yield f"data: {line}\n\n"
|
||||
last_stream_size = stream_size
|
||||
|
||||
# 轮询 Agent 事件
|
||||
agent_events_file = session_dir / "agent_events.log"
|
||||
last_agent_events_size = state["agent_size"]
|
||||
if agent_events_file.exists():
|
||||
agent_size = agent_events_file.stat().st_size
|
||||
if agent_size > last_agent_events_size:
|
||||
with open(agent_events_file, encoding="utf-8", errors="replace") as f:
|
||||
f.seek(last_agent_events_size)
|
||||
new_agent_events = f.read()
|
||||
if new_agent_events:
|
||||
for line in new_agent_events.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
yield f"data: {line}\n\n"
|
||||
state["agent_size"] = agent_size
|
||||
|
||||
# 检查是否完成
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
with open(result_file, encoding="utf-8") as f:
|
||||
result = json.load(f)
|
||||
yield f"data: {sse_handler.escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n"
|
||||
break
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/api/submit-financial/<session_id>", methods=["POST"])
|
||||
def submit_financial(session_id: str) -> Any:
|
||||
"""手动触发财务系统填报"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
config_path = session_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
return jsonify({"error": "未找到配置,请先配置后处理"}), 400
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
result_file.unlink()
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
submit_result = pipeline_web.run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {"ok": True}
|
||||
else:
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Agent 交互 API
|
||||
# ================================================================
|
||||
|
||||
|
||||
@web_bp.route("/api/agent/state/<session_id>", methods=["GET"])
|
||||
def get_agent_state(session_id: str) -> Any:
|
||||
"""获取 Agent 会话状态"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
from src.agent import load_agent_state
|
||||
|
||||
session = load_agent_state(session_dir)
|
||||
if session is None:
|
||||
return jsonify({"error": "未找到 Agent 状态,请先处理"}), 404
|
||||
|
||||
return jsonify(session.to_dict())
|
||||
|
||||
|
||||
@web_bp.route("/api/agent/process/<session_id>", methods=["POST"])
|
||||
def agent_process(session_id: str) -> Any:
|
||||
"""启动 Agent 多轮处理流程"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
config = _build_web_config(body)
|
||||
|
||||
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from src.agent import (
|
||||
AgentSession,
|
||||
AgentState,
|
||||
load_agent_state,
|
||||
run_agent_round,
|
||||
save_agent_state,
|
||||
)
|
||||
from src.doc.extractor import extract_invoices
|
||||
from src.doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
agent_session = load_agent_state(session_dir)
|
||||
if agent_session is None:
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
if not invoices:
|
||||
result = {"ok": False, "error": "未提取到任何发票数据"}
|
||||
return
|
||||
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
pipeline_web.save_invoice_groups(session_dir, groups)
|
||||
|
||||
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
|
||||
agent_session = AgentSession(
|
||||
session_id=session_id,
|
||||
invoice_type="travel" if is_travel else "normal",
|
||||
)
|
||||
save_agent_state(session_dir, agent_session)
|
||||
|
||||
agent_session = run_agent_round(session_dir, agent_session)
|
||||
|
||||
if agent_session.state == AgentState.READY:
|
||||
# Agent 校验通过,直接触发财务提交
|
||||
_emit_ready_and_submit(session_dir, agent_session, config)
|
||||
elif agent_session.state == AgentState.AWAITING_SUPPLEMENT:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": False,
|
||||
"agent_state": agent_session.state.value,
|
||||
"round": agent_session.rounds,
|
||||
"waiting_for_supplement": True,
|
||||
}
|
||||
elif agent_session.state == AgentState.ERROR:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": agent_session.error_message,
|
||||
}
|
||||
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if not result_file.exists():
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@web_bp.route("/api/agent/supplement/<session_id>", methods=["POST"])
|
||||
def agent_supplement(session_id: str) -> Any:
|
||||
"""用户补充文件后触发新一轮 Agent 处理"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
filenames = body.get("files", [])
|
||||
|
||||
if not filenames:
|
||||
return jsonify({"error": "未指定补充文件"}), 400
|
||||
|
||||
from src.agent import (
|
||||
AgentState,
|
||||
add_supplement,
|
||||
load_agent_state,
|
||||
run_agent_round,
|
||||
)
|
||||
|
||||
agent_session = load_agent_state(session_dir)
|
||||
if agent_session is None:
|
||||
return jsonify({"error": "未找到 Agent 状态"}), 404
|
||||
|
||||
agent_session = add_supplement(session_dir, agent_session, filenames)
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from src.doc.extractor import extract_invoices
|
||||
from src.doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
pipeline_web.save_invoice_groups(session_dir, groups)
|
||||
|
||||
new_session = run_agent_round(session_dir, agent_session, new_files=filenames)
|
||||
|
||||
if new_session.state == AgentState.READY:
|
||||
# Agent 校验通过,直接触发财务提交
|
||||
config_path = session_dir / "config.json"
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
_emit_ready_and_submit(session_dir, new_session, config)
|
||||
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": False,
|
||||
"agent_state": new_session.state.value,
|
||||
"round": new_session.rounds,
|
||||
"waiting_for_supplement": True,
|
||||
}
|
||||
elif new_session.state == AgentState.ERROR:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": new_session.error_message,
|
||||
}
|
||||
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if result.get("error") and not result_file.exists():
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@web_bp.route("/api/agent/user-supplement/<session_id>", methods=["POST"])
|
||||
def agent_user_supplement(session_id: str) -> Any:
|
||||
"""用户通过文字补充信息,LLM 分析后更新 JSON,重新校验"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
user_text = body.get("text", "").strip()
|
||||
|
||||
if not user_text:
|
||||
return jsonify({"error": "请输入补充信息"}), 400
|
||||
|
||||
from src.agent import (
|
||||
AgentState,
|
||||
load_agent_state,
|
||||
process_user_text_supplement,
|
||||
)
|
||||
|
||||
agent_session = load_agent_state(session_dir)
|
||||
if agent_session is None:
|
||||
return jsonify({"error": "未找到 Agent 状态"}), 404
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_session = process_user_text_supplement(session_dir, agent_session, user_text)
|
||||
|
||||
if new_session.state == AgentState.READY:
|
||||
# Agent 校验通过,直接触发财务提交
|
||||
config_path = session_dir / "config.json"
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
_emit_ready_and_submit(session_dir, new_session, config)
|
||||
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": False,
|
||||
"agent_state": new_session.state.value,
|
||||
"round": new_session.rounds,
|
||||
"waiting_for_supplement": True,
|
||||
}
|
||||
elif new_session.state == AgentState.ERROR:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": new_session.error_message,
|
||||
}
|
||||
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if not result_file.exists():
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@web_bp.route("/api/agent/force-submit/<session_id>", methods=["POST"])
|
||||
def agent_force_submit(session_id: str) -> Any:
|
||||
"""用户强制提交,跳过校验"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
|
||||
from src.agent import (
|
||||
force_submit,
|
||||
load_agent_state,
|
||||
)
|
||||
|
||||
agent_session = load_agent_state(session_dir)
|
||||
if agent_session is None:
|
||||
return jsonify({"error": "未找到 Agent 状态"}), 404
|
||||
|
||||
agent_session = force_submit(session_dir, agent_session)
|
||||
|
||||
config_path = session_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
return jsonify({"error": "未找到配置"}), 400
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
result_file.unlink()
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
submit_result = pipeline_web.run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {"ok": True}
|
||||
else:
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"status": "started"})
|
||||
84
src/web/sse_handler.py
Normal file
84
src/web/sse_handler.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
SSE 日志流处理
|
||||
|
||||
负责:
|
||||
- 日志收集器安装/卸载
|
||||
- SSE 数据转义
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
SESSION_LOG_FILE = "session.log"
|
||||
|
||||
# 需要监控日志的模块名称列表
|
||||
LOG_TARGET_NAMES = [
|
||||
"extractor",
|
||||
"llm_extractor",
|
||||
"matcher",
|
||||
"pipeline",
|
||||
"bot",
|
||||
"fill_consumable_doc",
|
||||
"agent",
|
||||
"validator",
|
||||
]
|
||||
|
||||
|
||||
class SSELogHandler(logging.Handler):
|
||||
"""将日志写入指定文件(线程安全)"""
|
||||
|
||||
def __init__(self, log_path: Path):
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self._file = open(log_path, "w", encoding="utf-8")
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
msg = self.format(record) + "\n"
|
||||
with self._lock:
|
||||
self._file.write(msg)
|
||||
self._file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close_file(self) -> None:
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def install_log_collector(session_dir: Path) -> SSELogHandler:
|
||||
"""安装日志收集器到各模块"""
|
||||
log_path = session_dir / SESSION_LOG_FILE
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler = SSELogHandler(log_path)
|
||||
handler.setFormatter(fmt)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
for name in LOG_TARGET_NAMES:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def remove_log_collector(handler: SSELogHandler) -> None:
|
||||
"""卸载日志收集器"""
|
||||
for name in LOG_TARGET_NAMES:
|
||||
logging.getLogger(name).removeHandler(handler)
|
||||
handler.close_file()
|
||||
|
||||
|
||||
def escape_sse(text: str) -> str:
|
||||
"""SSE 数据转义,同时处理 Windows 行尾 \r\n"""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
last_reviewed: 2026-06-11
|
||||
last_reviewed: 2026-06-12
|
||||
---
|
||||
|
||||
# src/web/static — 静态资源目录
|
||||
@@ -15,4 +15,12 @@ last_reviewed: 2026-06-11
|
||||
|
||||
## 技术栈
|
||||
|
||||
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
|
||||
原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。
|
||||
|
||||
## 变更记录
|
||||
|
||||
- **2026-06-12**:修复配置收集流程的异步时序问题
|
||||
- `parseConfigFile` 改为返回 Promise,`handleFiles` 和拖拽 `drop` 处理器改为 `async/await`,确保 config.json 解析完成后再执行检查,消息显示顺序正确
|
||||
- `sendUserMessage` 修复变量名错误(`pendingConfigKeys` → `pendingConfigKey`),修复了配置收集卡死的问题
|
||||
- 拆分 `checkAutoStart` 为两个函数:`checkAutoStart` 仅做静默检查(由 `syncFiles` 轮询调用),`promptMissingConfig` 负责配置提示(由用户主动上传完成后调用),避免轮询提前触发配置提示导致消息乱序
|
||||
- 移除所有聊天消息的删除逻辑,聊天窗口保留完整历史(欢迎消息、文件通知、配置交互、处理结果均不删除)
|
||||
@@ -1,16 +1,241 @@
|
||||
body { background: #f5f7fa; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
|
||||
body {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
|
||||
cursor: pointer; transition: all .2s; background: #fff; height: 100px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
|
||||
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
|
||||
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
|
||||
.upload-zone .icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.upload-zone .icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 可折叠上传面板(在文档流中,向上推挤聊天窗口) */
|
||||
.upload-panel {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.upload-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
background: #fafbfc;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.upload-panel-header:hover {
|
||||
background: #f0f2ff;
|
||||
}
|
||||
.upload-panel-toggle {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.upload-panel-toggle.collapsed {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.upload-panel-body {
|
||||
padding: 16px;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
||||
max-height: 400px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.upload-panel-body.collapsed {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
|
||||
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
|
||||
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-container .empty { color: #666; font-style: italic; }
|
||||
.chat-container {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
flex: 1 1 0;
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scroll-behavior: smooth;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
border-radius: 20px;
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-avatar img {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-bubble.system {
|
||||
background: #f0f2f5;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.processing {
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.success {
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.error {
|
||||
background: #fce8e6;
|
||||
color: #c5221f;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble.done {
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
border-bottom-left-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-bubble.user {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* 打字指示器 */
|
||||
.typing-indicator {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #667eea;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
|
||||
.btn-process { font-size: 17px; padding: 10px 40px; }
|
||||
.status-badge { font-size: 13px; }
|
||||
@@ -21,4 +246,260 @@ body { background: #f5f7fa; }
|
||||
.table-editable td { vertical-align: middle; }
|
||||
.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; }
|
||||
.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; }
|
||||
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
|
||||
/* ================================================================ */
|
||||
/* 文件消息样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.file-bubble {
|
||||
background: #fafbfc;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-bottom-left-radius: 4px;
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
.file-bubble.file-processing {
|
||||
background: #f0f4ff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.file-bubble.file-done {
|
||||
background: #f0fff4;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.file-bubble.file-error {
|
||||
background: #fff5f5;
|
||||
border-color: #c5221f;
|
||||
}
|
||||
|
||||
.file-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.file-status-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 文件消息的三点动画(与文件名同一行) */
|
||||
.file-typing-indicator {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.file-typing-indicator span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #667eea;
|
||||
animation: fileTyping 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.file-typing-indicator span:nth-child(1) { animation-delay: 0s; }
|
||||
.file-typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.file-typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes fileTyping {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 文件详情区域 */
|
||||
.file-detail {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #ddd;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.detail-value.error-text {
|
||||
color: #c5221f;
|
||||
}
|
||||
|
||||
/* ================================================================ */
|
||||
/* LLM 流式聊天气泡样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.llm-stream-bubble {
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #667eea;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.llm-stream-bubble.done {
|
||||
background: #e6f4ea;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.llm-stream-bubble.error {
|
||||
background: #fce8e6;
|
||||
border-color: #c5221f;
|
||||
}
|
||||
|
||||
.llm-stream-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.llm-stream-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 可折叠的思考过程区域 */
|
||||
.llm-reasoning-section {
|
||||
margin: 6px 0;
|
||||
padding: 4px 0;
|
||||
border-top: 1px dashed #ddd;
|
||||
border-bottom: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
.llm-reasoning-summary {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.llm-reasoning-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.llm-reasoning-summary::before {
|
||||
content: '▶';
|
||||
font-size: 8px;
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.llm-reasoning-section[open] > .llm-reasoning-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.llm-reasoning-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #777;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
margin-top: 4px;
|
||||
background: #fafbfc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ================================================================ */
|
||||
/* Agent 请求面板样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.agent-request-panel {
|
||||
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin: 8px 0;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-request-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #856404;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.agent-request-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-request-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.agent-request-materials {
|
||||
font-size: 13px;
|
||||
color: #856404;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.material-tag {
|
||||
display: inline-block;
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
border-radius: 4px;
|
||||
padding: 2px 10px;
|
||||
margin: 2px 4px 2px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.agent-request-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-request-actions .btn {
|
||||
font-size: 13px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
1
src/web/static/icon/agent.svg
Normal file
1
src/web/static/icon/agent.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319741857" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1691" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M690.185 275.766c88.365 0 160 71.634 160 160v239.488c0 88.365-71.635 160-160 160H339.766c-88.366 0-160-71.635-160-160V435.766c0-88.366 71.634-160 160-160h350.419z m-350.419 64c-53.02 0-96 42.98-96 96v239.488c0 53.019 42.98 96 96 96h350.419c53.019 0 96-42.981 96-96V435.766c0-53.02-42.981-96-96-96H339.766z m56.28 120.882c52.056 0.001 94.256 42.201 94.256 94.257 0 52.057-42.2 94.256-94.256 94.256s-94.257-42.199-94.257-94.256c0-52.056 42.2-94.257 94.257-94.257z m240.348 0c52.056 0.001 94.255 42.201 94.255 94.257 0 52.057-42.199 94.256-94.255 94.256-52.057 0-94.257-42.199-94.257-94.256 0-52.056 42.2-94.257 94.257-94.257zM128 481.562c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m769.113 0c17.673 0 32 14.327 32 32v83.907c0 17.673-14.327 32-32 32-17.673 0-32-14.327-32-32v-83.907c0-17.673 14.327-32 32-32z m-501.067 43.086c-16.71 0-30.257 13.547-30.257 30.257s13.547 30.256 30.257 30.256 30.256-13.546 30.256-30.256-13.546-30.256-30.256-30.257z m240.348 0c-16.711 0-30.257 13.547-30.257 30.257s13.546 30.256 30.257 30.256c16.71 0 30.255-13.546 30.255-30.256s-13.545-30.256-30.255-30.257zM556.931 192c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32h-83.908c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32h83.908z" fill="#1296db" p-id="1692"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
src/web/static/icon/file.svg
Normal file
1
src/web/static/icon/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319878861" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10502" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M716.8 704h-51.2v-25.6h38.4V140.8H320v38.4h-25.6v-51.2a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8zM806.4 614.4h-51.2v-25.6h38.4V230.4h25.6v371.2a12.8 12.8 0 0 1-12.8 12.8zM793.6 179.2h25.6v25.6h-25.6z" fill="#1296db" p-id="10503"></path><path d="M819.2 153.6h-25.6V51.2H409.6v38.4h-25.6V38.4a12.8 12.8 0 0 1 12.8-12.8h409.6a12.8 12.8 0 0 1 12.8 12.8v115.2zM627.2 793.6H217.6a12.8 12.8 0 0 1-12.8-12.8V217.6a12.8 12.8 0 0 1 12.8-12.8h243.2v25.6H230.4v537.6h384V230.4h-76.8v-25.6h89.6a12.8 12.8 0 0 1 12.8 12.8v563.2a12.8 12.8 0 0 1-12.8 12.8z" fill="#1296db" p-id="10504"></path><path d="M486.4 204.8h25.6v25.6h-25.6zM268.8 448h307.2v25.6H268.8zM268.8 524.8h307.2v25.6H268.8zM512 601.6h64v25.6h-64zM460.8 601.6h25.6v25.6h-25.6zM268.8 601.6h166.4v25.6H268.8zM268.8 678.4h307.2v25.6H268.8zM268.8 294.4h307.2v25.6H268.8zM409.6 371.2h166.4v25.6H409.6zM358.4 371.2h25.6v25.6h-25.6zM268.8 371.2h64v25.6h-64z" fill="#1296db" p-id="10505"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/web/static/icon/folder.svg
Normal file
1
src/web/static/icon/folder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319817389" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6095" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#1296db" p-id="6096"></path></svg>
|
||||
|
After Width: | Height: | Size: 727 B |
1
src/web/static/icon/gallery.svg
Normal file
1
src/web/static/icon/gallery.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319921047" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14940" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M746.6496 198.4a129.6384 129.6384 0 0 1 129.536 124.0064l0.1024 5.632v367.9232a129.6384 129.6384 0 0 1-124.0064 129.536l-5.632 0.1024H306.9184a129.6384 129.6384 0 0 1-129.536-124.0064l-0.128-5.632v-57.9328c0-7.4752 2.6368-14.6944 7.3728-20.4032l2.5344-2.7136 131.84-126.0544a32 32 0 0 1 35.4304-5.9648l3.1488 1.664 116.0704 69.5808 74.7008-66.0992a32 32 0 0 1 35.072-4.864l3.072 1.6896 177.1008 110.6688a32 32 0 0 1-30.848 55.9616l-3.072-1.6896-156.8256-97.9968-74.3424 65.792a32 32 0 0 1-34.6112 5.12l-3.072-1.6384-115.2512-69.0944-104.32 99.712v44.2624a65.6384 65.6384 0 0 0 61.4912 65.5104l4.1728 0.128h439.7312a65.6384 65.6384 0 0 0 65.5104-61.4912l0.128-4.1472V328.0384a65.6384 65.6384 0 0 0-61.4912-65.5104l-4.1472-0.128H306.9184a65.6384 65.6384 0 0 0-65.536 61.4912l-0.128 4.1472v134.3488a32 32 0 0 1-63.8208 3.2768l-0.1792-3.2768v-134.3488a129.6384 129.6384 0 0 1 124.032-129.536l5.632-0.1024h439.7312z" fill="#FB553C" p-id="14941"></path><path d="M692.5312 398.3104m-47.4112 0a47.4112 47.4112 0 1 0 94.8224 0 47.4112 47.4112 0 1 0-94.8224 0Z" fill="#FB553C" p-id="14942"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/web/static/icon/phone.svg
Normal file
1
src/web/static/icon/phone.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1781319971806" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19425" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M820.409449 797.228346q0 25.19685-10.07874 46.866142t-27.716535 38.299213-41.322835 26.204724-50.897638 9.574803l-357.795276 0q-27.212598 0-50.897638-9.574803t-41.322835-26.204724-27.716535-38.299213-10.07874-46.866142l0-675.275591q0-25.19685 10.07874-47.370079t27.716535-38.80315 41.322835-26.204724 50.897638-9.574803l357.795276 0q27.212598 0 50.897638 9.574803t41.322835 26.204724 27.716535 38.80315 10.07874 47.370079l0 675.275591zM738.771654 170.330709l-455.559055 0 0 577.511811 455.559055 0 0-577.511811zM510.992126 776.062992q-21.165354 0-36.787402 15.11811t-15.622047 37.291339q0 21.165354 15.622047 36.787402t36.787402 15.622047q22.173228 0 37.291339-15.622047t15.11811-36.787402q0-22.173228-15.11811-37.291339t-37.291339-15.11811zM591.622047 84.661417q0-8.062992-5.03937-12.598425t-11.086614-4.535433l-128 0q-5.03937 0-10.582677 4.535433t-5.543307 12.598425 5.03937 12.598425 11.086614 4.535433l128 0q6.047244 0 11.086614-4.535433t5.03937-12.598425z" p-id="19426" fill="#1296db"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/web/static/icon/user.svg
Normal file
1
src/web/static/icon/user.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
186
src/web/static/js/README.md
Normal file
186
src/web/static/js/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
last_reviewed: 2026-06-16
|
||||
---
|
||||
|
||||
# src/web/static/js — 前端逻辑模块
|
||||
|
||||
## 概述
|
||||
|
||||
财务报销自动化系统的前端入口脚本,按功能拆分为多个模块文件,通过 `App` 共享状态对象通信。采用原生 JavaScript 编写,通过 HTTP 请求与 Flask 后端通信,使用 SSE 接收处理进度。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `state.js` | 全局状态管理 (`App` 对象,含处理状态机) |
|
||||
| `utils.js` | 工具函数 (HTML 转义、会话管理) |
|
||||
| `chat.js` | 聊天窗口消息管理(含文件消息、处理进度、三点动画、LLM 流式气泡) |
|
||||
| `upload.js` | 文件上传(含拖拽、config.json 解析) |
|
||||
| `config.js` | 配置收集与校验(基于状态机的自动触发) |
|
||||
| `process.js` | 发票处理流程与 SSE 监听(含 file_progress、llm_stream 事件处理) |
|
||||
| `agent.js` | Agent 交互(含 supplement 流程、强制提交、用户文字补充、自动提交) |
|
||||
| `sync.js` | 移动端同步轮询(状态感知模式) |
|
||||
| `index.js` | 入口初始化 |
|
||||
|
||||
## 加载顺序
|
||||
|
||||
```
|
||||
state.js → utils.js → chat.js → upload.js → config.js → process.js → sync.js → index.js
|
||||
```
|
||||
|
||||
## 模块依赖关系
|
||||
|
||||
```
|
||||
state.js (无依赖)
|
||||
│
|
||||
├── utils.js ────────────────┐
|
||||
│ │
|
||||
├── chat.js ─────────────────┐ │
|
||||
│ │ │
|
||||
├── upload.js ──────────────┐ │ │
|
||||
│ │ │ │
|
||||
├── config.js ─────────────┐ │ │ │
|
||||
│ │ │ │ │
|
||||
├── process.js ───────────┐ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
├── sync.js ──────────┐ │ │ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
└── index.js ────────┘ │ │ │ │ │ │
|
||||
```
|
||||
|
||||
## 处理状态机
|
||||
|
||||
`App.processState` 控制何时触发自动处理,避免重复执行:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
|
||||
idle --> processing: 配置完整 + 有文件
|
||||
processing --> done: 处理完成
|
||||
processing --> awaiting_supplement: Agent 要求补充材料
|
||||
|
||||
done --> idle: 有新文件 / forceStart = true
|
||||
done --> done: 无新文件(保持,不重复)
|
||||
|
||||
awaiting_supplement --> idle: 用户补充文件(只上传新文件)
|
||||
|
||||
idle --> processing: idle + newFilenames > 0(走 supplement 流程)
|
||||
idle --> awaiting_files: AI 判断文件不足(未来功能)
|
||||
awaiting_files --> idle: 用户补充文件
|
||||
awaiting_files --> processing: 用户说"直接开始" (forceStart = true)
|
||||
```
|
||||
|
||||
状态字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `processState` | string | 当前处理状态:idle / processing / done / awaiting_files / awaiting_supplement |
|
||||
| `lastProcessedFileCount` | number | 上次处理时的文件数,用于检测是否有新文件 |
|
||||
| `forceStart` | boolean | 用户强制开始标记(兼容"文件不足也要处理"的场景) |
|
||||
| `newFilenames` | array | 待补充上传的文件名列表,用于区分首次处理和补充处理 |
|
||||
|
||||
## 核心流程
|
||||
|
||||
### 1. 文件上传
|
||||
|
||||
```
|
||||
用户上传文件 → addFileMessage() → 注册到 App.fileMessageMap → promptMissingConfig()
|
||||
→ processState 为 done 时重置为 idle(支持增量上传后重新处理)
|
||||
```
|
||||
|
||||
### 2. 配置收集
|
||||
|
||||
```
|
||||
promptMissingConfig() → 检查 CONFIG_FIELDS → 有缺失则提示 → 用户输入 → sendUserMessage() → 递归直到完整
|
||||
```
|
||||
|
||||
### 3. 自动触发检查 (checkAutoStart)
|
||||
|
||||
```
|
||||
checkAutoStart() → 状态机判断:
|
||||
- processing → 跳过
|
||||
- done + 无新文件 → 跳过(修复重复执行问题)
|
||||
- done + 有新文件 → 走 supplement 流程(不再走 startProcess)
|
||||
- idle + newFilenames > 0 → 走 supplement 流程
|
||||
- idle + 配置完整 + 有文件 → 走 startProcess(首次处理)
|
||||
- awaiting_files + forceStart → 触发
|
||||
```
|
||||
|
||||
### 4. 发票处理(首次)
|
||||
|
||||
```
|
||||
startProcess() → ensureSession → 上传全部文件 → /api/agent/process → SSE 监听
|
||||
→ 收到 file_progress(processing) → setFileProcessing()(三点动画)
|
||||
→ 收到 file_progress(done) → setFileDone()(展示提取摘要)
|
||||
→ 收到 file_progress(cached) → setFileCached()
|
||||
→ 收到 file_progress(error) → setFileError()
|
||||
→ 收到 llm_stream(start) → handleLLMStream() 创建流式气泡
|
||||
→ 收到 llm_stream(chunk) → handleLLMStream() 逐字追加 AI 输出
|
||||
→ 收到 llm_stream(end) → handleLLMStream() 关闭流式气泡
|
||||
→ 收到 done → 设置 processState = 'done' + 记录文件数 → 显示汇总结果
|
||||
```
|
||||
|
||||
### 5. 补充文件处理(新增)
|
||||
|
||||
当 Agent 返回 `awaiting_supplement` 状态时,用户补充文件的流程:
|
||||
|
||||
```
|
||||
用户上传补充文件
|
||||
→ handleFiles() 检测 processState === 'awaiting_supplement'
|
||||
→ _uploadNewFiles() 只上传新文件(不再重新上传所有文件)
|
||||
→ App.newFilenames = [新文件名列表]
|
||||
→ App.processState = 'idle'
|
||||
→ syncFiles() 3 秒后触发 checkAutoStart()
|
||||
→ checkAutoStart() 检测到 idle + newFilenames > 0
|
||||
→ handleSupplementUpload(filenames)
|
||||
→ POST /api/agent/supplement/(后端会重新 extract_invoices,旧文件走缓存)
|
||||
→ 新文件走 LLM 提取,旧文件命中 JSON 缓存直接返回
|
||||
→ run_agent_round() 重新分析
|
||||
→ SSE 返回 done → 根据结果进入 awaiting_supplement 或 done 状态
|
||||
```
|
||||
|
||||
**关键区别**:补充文件走 `/api/agent/supplement/` 而非 `/api/agent/process/`,后者在已有 agent_session 时会跳过提取阶段。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 配置数据流
|
||||
|
||||
```
|
||||
config.json → parseConfigFile → App.sessionConfig → startProcess → /api/agent/process
|
||||
```
|
||||
|
||||
### 发票数据流(首次处理)
|
||||
|
||||
```
|
||||
发票文件 → startProcess → /api/upload → /api/agent/process → SSE file_events.log → 文件消息实时更新
|
||||
```
|
||||
|
||||
### 发票数据流(补充文件)
|
||||
|
||||
```
|
||||
新发票文件 → _uploadNewFiles() → /api/upload → /api/agent/supplement
|
||||
→ extract_invoices()(旧文件走缓存,新文件走 LLM)
|
||||
→ run_agent_round() → SSE file_events.log → 文件消息实时更新
|
||||
```
|
||||
|
||||
### 文件消息状态机
|
||||
|
||||
```
|
||||
addFileMessage(filename)
|
||||
→ file_progress(processing) → 三点动画(同一行)
|
||||
→ file_progress(done/cached/error) → 详情展开 + 状态图标
|
||||
```
|
||||
|
||||
## 变更记录
|
||||
|
||||
- **2026-06-16**:修复补充文件重复上传问题 — 新增 `newFilenames` 状态字段,补充文件时只上传新文件并走 `/api/agent/supplement/` 接口。`upload.js` 新增 `_uploadNewFiles()` 辅助函数。`config.js` 的 `checkAutoStart()` 在 `done` 和 `idle` 状态下有 `newFilenames` 时走 supplement 流程而非 `startProcess()`
|
||||
- **2026-06-16**:新增 LLM 流式思考展示 — `chat.js` 新增 `handleLLMStream` 状态机(start/chunk/end/error 四阶段),`process.js` 的 SSE 监听增加 `llm_stream` 事件分支,AI 思考过程以聊天气泡逐字展示
|
||||
- **2026-06-16**:引入处理状态机 — 新增 `processState` / `lastProcessedFileCount` / `forceStart` 状态字段,修复 `syncFiles` 轮询每 3 秒重复触发处理的 bug。`checkAutoStart` 改为基于状态机判断,done 状态下只有检测到新文件才重新触发。为未来 AI 判断文件是否充足预留了 `awaiting_files` 状态和 `forceStart` 标记
|
||||
- **2026-06-12**:上传区域可折叠 — 新增 `toggleUploadPanel()` 函数,配合 CSS `max-height` 动画实现面板收起/展开
|
||||
- **2026-06-12**:文件消息框对齐到用户侧 — `addFileMessage` 的 wrapper 新增 `user` 类,上传的文件消息显示在右侧(用户侧),与 agent 消息区分
|
||||
- **2026-06-12**:配置完成增加互动提示 — `promptMissingConfig` 在配置完整时发送系统消息告知用户即将开始处理发票
|
||||
- **2026-06-14**:新增文件进度实时反馈 — 后端 extractor 在每处理一个文件时产生 SSE 进度事件,前端根据文件名定位 DOM 并更新状态(处理中三点动画、完成摘要展示、缓存/错误状态)
|
||||
- **2026-06-14**:移除发票编辑和财务提交流程 — 删除 table.js、financial.js 及相关 HTML 区域,后续开发不再支持手动编辑发票信息
|
||||
- **2026-06-12**:模块拆分重构 — 将 index.js (625 行) 拆分为 10 个独立模块,引入 `App` 共享状态对象
|
||||
- **2026-06-12**:修复配置收集流程的异步时序问题 — parseConfigFile 改为 Promise,拆分 checkAutoStart 为静默检查/配置提示两个函数
|
||||
439
src/web/static/js/agent.js
Normal file
439
src/web/static/js/agent.js
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Agent 交互模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { escapeHtml } from './utils.js';
|
||||
import { addChatMessage, showStatus } from './chat.js';
|
||||
|
||||
// ================================================================
|
||||
// Agent 事件处理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理 Agent SSE 事件
|
||||
*
|
||||
* @param {Object} msg - SSE 传来的 agent 事件对象
|
||||
*/
|
||||
export function handleAgentEvent(msg) {
|
||||
switch (msg.type) {
|
||||
case 'agent_state_change':
|
||||
_handleAgentStateChange(msg);
|
||||
break;
|
||||
case 'agent_request_supplement':
|
||||
_handleAgentRequestSupplement(msg);
|
||||
break;
|
||||
case 'agent_ready':
|
||||
_handleAgentReady(msg);
|
||||
break;
|
||||
case 'agent_error':
|
||||
_handleAgentError(msg);
|
||||
break;
|
||||
case 'agent_supplement_received':
|
||||
_handleSupplementReceived(msg);
|
||||
break;
|
||||
case 'agent_force_submit':
|
||||
_handleForceSubmit(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态变更通知 — 使用瞬态消息,状态变化时更新而非追加
|
||||
*/
|
||||
function _handleAgentStateChange(msg) {
|
||||
const stateLabels = {
|
||||
'extracting': '正在分析文件...',
|
||||
'validating': '正在校验信息完整性...',
|
||||
'awaiting_supplement': '等待补充材料',
|
||||
'ready': '信息完整,可以提交',
|
||||
'submitting': '正在提交...',
|
||||
};
|
||||
|
||||
const label = stateLabels[msg.state] || msg.message || '处理中...';
|
||||
|
||||
if (msg.state === 'ready') {
|
||||
showStatus(label, 'done');
|
||||
} else {
|
||||
showStatus(label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求补充材料 — 仅更新瞬态状态,持久提示消息由 done 事件分支负责
|
||||
*/
|
||||
export function _handleAgentRequestSupplement(msg) {
|
||||
const suggestion = msg.suggestion || '请补充上传相关材料';
|
||||
showStatus(suggestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息完整 — 已由 process.js 的 done 处理覆盖,此处不再重复显示
|
||||
*/
|
||||
function _handleAgentReady(msg) {
|
||||
// agent_ready 事件的信息展示统一由 process.js done 分支处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动触发财务系统提交(由 done 事件调用)
|
||||
*/
|
||||
export async function handleAutoSubmit() {
|
||||
if (!App.sessionId) {
|
||||
addChatMessage('请先上传文件并处理', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (App.agentEventSource) {
|
||||
App.agentEventSource.close();
|
||||
App.agentEventSource = null;
|
||||
}
|
||||
|
||||
App.isProcessing = true;
|
||||
App.processState = 'submitting';
|
||||
|
||||
try {
|
||||
showStatus('正在自动提交到财务系统...');
|
||||
|
||||
const response = await fetch(`/api/submit-financial/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
if (msg.result.submit_ok) {
|
||||
addChatMessage('提交完成!', 'done');
|
||||
} else {
|
||||
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志行
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
} else if (result.error) {
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage(`提交失败:${result.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 错误
|
||||
*/
|
||||
function _handleAgentError(msg) {
|
||||
addChatMessage(msg.message || 'Agent 处理出错', 'error');
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到补充文件
|
||||
*/
|
||||
function _handleSupplementReceived(msg) {
|
||||
const fileNames = (msg.files || []).join('、');
|
||||
addChatMessage(`已收到补充文件:${fileNames}`, 'done');
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制提交
|
||||
*/
|
||||
function _handleForceSubmit(msg) {
|
||||
showStatus('已跳过校验,开始提交到财务系统');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Agent 请求面板
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 显示 Agent 请求补充材料的 UI
|
||||
*/
|
||||
export function showAgentRequest(data) {
|
||||
const existing = document.getElementById('agent-request-panel');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
if (!chatMessages) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'agent-request-panel';
|
||||
panel.className = 'agent-request-panel';
|
||||
|
||||
let materialsHtml = '';
|
||||
if (data.missing_materials && data.missing_materials.length > 0) {
|
||||
materialsHtml = `
|
||||
<div class="agent-request-materials">
|
||||
<strong>需要补充:</strong>
|
||||
${data.missing_materials.map(m => `<span class="material-tag">${escapeHtml(m)}</span>`).join(' ')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="agent-request-header">
|
||||
<span class="agent-request-icon"><img src="/static/icon/file.svg" alt=""></span>
|
||||
<span>第 ${data.round || 1} 轮分析 - 信息不完整</span>
|
||||
</div>
|
||||
${materialsHtml}
|
||||
<div class="agent-request-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="document.getElementById('file-input').click()">
|
||||
上传补充材料
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="handleForceSubmit()">
|
||||
直接提交
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
chatMessages.appendChild(panel);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Agent 请求面板
|
||||
*/
|
||||
export function hideAgentRequest() {
|
||||
const panel = document.getElementById('agent-request-panel');
|
||||
if (panel) panel.remove();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 用户文字补充
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理用户通过输入框补充的文字信息
|
||||
*
|
||||
* @param {string} text - 用户输入的文字
|
||||
*/
|
||||
export async function handleUserSupplement(text) {
|
||||
if (!App.sessionId) return;
|
||||
|
||||
try {
|
||||
showStatus('正在解析补充信息...');
|
||||
|
||||
const response = await fetch(`/api/agent/user-supplement/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showStatus('补充信息已收到,正在重新分析...');
|
||||
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type && msg.type.startsWith('agent_')) {
|
||||
handleAgentEvent(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
|
||||
if (msg.result.waiting_for_supplement) {
|
||||
App.processState = 'awaiting_supplement';
|
||||
_handleAgentRequestSupplement(msg.result);
|
||||
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
|
||||
} else {
|
||||
App.processState = 'done';
|
||||
if (msg.result.ok) {
|
||||
if (msg.result.submit_ok !== false) {
|
||||
addChatMessage('信息完整,已自动提交到财务系统', 'done');
|
||||
} else {
|
||||
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
} else {
|
||||
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
} else if (result.error) {
|
||||
addChatMessage(`处理失败:${result.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 用户输入处理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理强制提交
|
||||
*/
|
||||
export async function handleForceSubmit() {
|
||||
if (!App.sessionId) {
|
||||
addChatMessage('请先上传文件并处理', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (App.agentEventSource) {
|
||||
App.agentEventSource.close();
|
||||
App.agentEventSource = null;
|
||||
}
|
||||
|
||||
App.forceSubmitting = true;
|
||||
|
||||
try {
|
||||
showStatus('正在跳过校验并提交...');
|
||||
hideAgentRequest();
|
||||
|
||||
const response = await fetch(`/api/agent/force-submit/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showStatus('已跳过校验,开始提交到财务系统');
|
||||
App.isProcessing = true;
|
||||
App.processState = 'submitting';
|
||||
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
App.forceSubmitting = false;
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
if (msg.result.ok) {
|
||||
addChatMessage('提交完成!', 'done');
|
||||
} else {
|
||||
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志行
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
App.forceSubmitting = false;
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
App.forceSubmitting = false;
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 补充文件处理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 处理补充文件上传完成后的重新分析
|
||||
*
|
||||
* @param {Array<string>} filenames - 新上传的文件名列表
|
||||
*/
|
||||
export async function handleSupplementUpload(filenames) {
|
||||
if (!App.sessionId) return;
|
||||
|
||||
try {
|
||||
showStatus('收到补充文件,正在重新分析...');
|
||||
hideAgentRequest();
|
||||
|
||||
const response = await fetch(`/api/agent/supplement/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ files: filenames }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type && msg.type.startsWith('agent_')) {
|
||||
handleAgentEvent(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
|
||||
if (msg.result.waiting_for_supplement) {
|
||||
App.processState = 'awaiting_supplement';
|
||||
showStatus('信息不完整,请补充上传相关材料');
|
||||
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
|
||||
} else {
|
||||
App.processState = 'done';
|
||||
if (msg.result.ok) {
|
||||
// 后端已自动提交,根据 submit_ok 显示结果
|
||||
if (msg.result.submit_ok !== false) {
|
||||
addChatMessage('信息完整,已自动提交到财务系统', 'done');
|
||||
} else {
|
||||
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
} else {
|
||||
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
28
src/web/static/js/chat.js
Normal file
28
src/web/static/js/chat.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 聊天窗口模块入口
|
||||
*
|
||||
* 从 chat/ 子模块重新导出所有公开函数,保持向后兼容。
|
||||
* 外部模块通过 import 方式使用,HTML 中的 script 标签直接加载本文件。
|
||||
*/
|
||||
|
||||
export {
|
||||
addChatMessage,
|
||||
addTypingIndicator,
|
||||
removeTypingIndicator,
|
||||
addFileMessage,
|
||||
setFileProcessing,
|
||||
setFileDone,
|
||||
setFileCached,
|
||||
setFileError,
|
||||
} from './chat/persistent.js';
|
||||
|
||||
// 瞬态消息
|
||||
export {
|
||||
showStatus,
|
||||
clearStatus,
|
||||
} from './chat/ephemeral.js';
|
||||
|
||||
// LLM 流式消息
|
||||
export {
|
||||
handleLLMStream,
|
||||
} from './chat/stream.js';
|
||||
198
src/web/static/js/chat/README.md
Normal file
198
src/web/static/js/chat/README.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# chat/ 模块说明
|
||||
|
||||
## 目录
|
||||
|
||||
- [架构概览](#架构概览)
|
||||
- [模块职责](#模块职责)
|
||||
- [消息类型](#消息类型)
|
||||
- [数据流](#数据流)
|
||||
- [状态管理](#状态管理)
|
||||
- [注意事项](#注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
chat/
|
||||
├── renderer.js # 底层 DOM 渲染工具
|
||||
├── persistent.js # 持久消息(永久保留在聊天历史中)
|
||||
├── ephemeral.js # 瞬态消息(只显示最新一条,新状态覆盖旧状态)
|
||||
└── stream.js # LLM 流式消息(处理中显示思考过程,结束后固化)
|
||||
|
||||
chat.js # 入口文件,统一 re-export 所有公开函数
|
||||
```
|
||||
|
||||
设计原则:将**持久消息**和**瞬态消息**分离,避免聊天历史被中间状态消息堆积。
|
||||
|
||||
---
|
||||
|
||||
## 模块职责
|
||||
|
||||
### renderer.js
|
||||
|
||||
最底层渲染工具,不维护任何状态。提供:
|
||||
|
||||
- `getChatMessages()` — 获取 `#chat-messages` 容器
|
||||
- `scrollToBottom()` — 滚动到底部
|
||||
- `createChatBubble(text, type, isUser)` — 创建消息气泡 DOM
|
||||
- `createSystemBubble(text, type)` — 创建系统气泡 DOM
|
||||
- `appendMessage(wrapper)` — 将消息追加到容器并滚动
|
||||
|
||||
### persistent.js
|
||||
|
||||
管理永久保留在聊天历史中的消息:
|
||||
|
||||
- `addChatMessage(text, type, isUser)` — 添加普通聊天消息
|
||||
- `addTypingIndicator()` / `removeTypingIndicator()` — 加载三点动画
|
||||
- `addFileMessage(filename)` — 添加文件上传消息
|
||||
- `setFileProcessing(filename)` — 文件处理中状态
|
||||
- `setFileDone(filename, summary)` — 文件处理完成,展开提取摘要
|
||||
- `setFileCached(filename)` — 文件使用缓存
|
||||
- `setFileError(filename, errorMsg)` — 文件处理错误
|
||||
|
||||
依赖 `App.fileMessageMap`(定义在 `state.js`)维护文件名到 DOM 元素的映射。
|
||||
|
||||
### ephemeral.js
|
||||
|
||||
管理瞬态状态消息,同一时刻只显示最新一条:
|
||||
|
||||
- `showStatus(text, type)` — 显示/更新状态消息
|
||||
- `clearStatus()` — 清除当前状态消息
|
||||
|
||||
内部维护 `ephemeralState` 对象记录当前活跃的状态气泡引用。重复调用 `showStatus` 时直接更新已有气泡的文本和样式,不创建新 DOM。
|
||||
|
||||
### stream.js
|
||||
|
||||
管理 LLM 流式响应的气泡生命周期:
|
||||
|
||||
- `handleLLMStream(msg)` — 根据 SSE 事件阶段分发处理
|
||||
|
||||
支持的阶段:
|
||||
|
||||
| phase | 说明 |
|
||||
|-------|------|
|
||||
| `start` | 创建流式气泡,初始化思考过程区域(默认展开) |
|
||||
| `reasoning` | 追加思考过程文本 |
|
||||
| `chunk` | 追加正式回复文本 |
|
||||
| `end` | 关闭气泡,切换为 `done` 样式,折叠思考过程区域,气泡保留在历史中 |
|
||||
| `error` | 切换为 `error` 样式,显示错误信息 |
|
||||
|
||||
内部维护 `llmStreamState` 对象记录当前活跃的流式气泡引用。
|
||||
|
||||
---
|
||||
|
||||
## 消息类型
|
||||
|
||||
### 持久消息
|
||||
|
||||
永久保留在聊天历史中,不会被自动清除:
|
||||
|
||||
- 用户输入的文字
|
||||
- 文件上传记录及其处理状态
|
||||
- LLM 流式响应的最终结果(`end` 阶段后固化)
|
||||
- 系统通知(如"信息完整,可以提交")
|
||||
- 错误消息
|
||||
|
||||
调用 `addChatMessage()` 或 `addFileMessage()` 创建。
|
||||
|
||||
### 瞬态消息
|
||||
|
||||
只显示最新一条,新状态覆盖旧状态:
|
||||
|
||||
- "正在分析文件..."
|
||||
- "正在校验信息完整性..."
|
||||
- "请输入登录账号:"
|
||||
- "配置信息已完整,开始处理发票……"
|
||||
- "收到补充文件,正在重新分析..."
|
||||
|
||||
调用 `showStatus()` 创建/更新,调用 `clearStatus()` 清除。
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
外部模块 (agent.js / config.js / process.js / upload.js / sync.js)
|
||||
│
|
||||
├── import { addChatMessage, addFileMessage, ... } from './chat.js'
|
||||
├── import { showStatus, clearStatus } from './chat.js'
|
||||
└── import { handleLLMStream } from './chat.js'
|
||||
│
|
||||
▼
|
||||
chat.js (re-export)
|
||||
│
|
||||
├── → chat/persistent.js ──→ chat/renderer.js
|
||||
├── → chat/ephemeral.js ──→ chat/renderer.js
|
||||
└── → chat/stream.js ──→ chat/renderer.js
|
||||
```
|
||||
|
||||
- 外部模块统一从 `chat.js` 导入函数
|
||||
- `chat.js` 只做 re-export,不引入循环依赖
|
||||
- 三个子模块通过 `renderer.js` 共享底层 DOM 操作
|
||||
- `persistent.js` 额外依赖 `state.js` 的 `App.fileMessageMap`
|
||||
|
||||
---
|
||||
|
||||
## 状态管理
|
||||
|
||||
### ephemeralState (ephemeral.js)
|
||||
|
||||
```js
|
||||
{
|
||||
wrapper: HTMLElement | null, // 状态消息的 wrapper 元素
|
||||
bubble: HTMLElement | null, // 状态气泡元素
|
||||
}
|
||||
```
|
||||
|
||||
- 初始为 `null`
|
||||
- `showStatus` 首次调用时创建并记录引用
|
||||
- 后续调用直接更新 `bubble.textContent` 和 `bubble.className`
|
||||
- `clearStatus` 时移除 DOM 并重置为 `null`
|
||||
|
||||
### llmStreamState (stream.js)
|
||||
|
||||
```js
|
||||
{
|
||||
wrapper: HTMLElement | null, // 流式消息 wrapper
|
||||
bubble: HTMLElement | null, // 流式气泡
|
||||
textContent: HTMLElement | null, // 正式文本容器
|
||||
accumulated: string, // 累积的正式文本
|
||||
reasoningContent: HTMLElement | null, // 思考过程容器
|
||||
reasoningAccumulated: string, // 累积的思考文本
|
||||
}
|
||||
```
|
||||
|
||||
- `start` 阶段创建并记录引用
|
||||
- `chunk` / `reasoning` 阶段追加文本
|
||||
- `end` / `error` 阶段重置为 `null`(DOM 保留在历史中)
|
||||
|
||||
### App.fileMessageMap (state.js)
|
||||
|
||||
```js
|
||||
Map<string, { wrapper, bubble, nameRow, detailRow }>
|
||||
```
|
||||
|
||||
- 文件名 → DOM 元素映射
|
||||
- `addFileMessage` 创建时写入
|
||||
- `setFileProcessing` / `setFileDone` / `setFileCached` / `setFileError` 读取并更新对应文件的状态
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **不要直接操作 `#chat-messages` 容器**。所有消息创建都通过本模块的 API 进行。
|
||||
|
||||
2. **瞬态消息和持久消息不要混用**。中间处理状态用 `showStatus`,最终结果用 `addChatMessage`。
|
||||
|
||||
3. **`showStatus` 不需要手动清除**。调用 `showStatus` 显示新状态时会自动覆盖旧状态;在处理流程结束时,后续的消息或状态会自然覆盖。
|
||||
|
||||
4. **LLM 流式气泡在 `end` 阶段后变为持久消息**。不需要额外调用 `addChatMessage` 来保留结果。
|
||||
|
||||
5. **SSE 事件必须包含 `start` 和 `end` 阶段**。缺少 `start` 会导致气泡未创建,缺少 `end` 会导致气泡一直处于处理中状态。详见 `.agents/docs/error-experience/2026-06-13-llm_query_text缺少start-end事件导致前端不显示.md`。
|
||||
|
||||
6. **`handleLLMStream` 不处理普通日志行**。SSE 的 `message` 事件中,只有 `type === 'llm_stream'` 的事件才会被转发到此模块。
|
||||
|
||||
7. **文件消息的 DOM 生命周期由 `fileMessageMap` 管理**。文件处理完成后,摘要信息会展开显示在文件气泡下方。
|
||||
|
||||
8. **所有模块通过 `chat.js` 统一导入**,不要直接从 `chat/` 子目录导入(外部模块层面)。子模块之间的内部导入不受此限制。
|
||||
62
src/web/static/js/chat/ephemeral.js
Normal file
62
src/web/static/js/chat/ephemeral.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 瞬态消息管理器
|
||||
*
|
||||
* 瞬态消息只保留最新一条,新状态覆盖旧状态,不会堆积在聊天历史中。
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { getChatMessages, scrollToBottom } from './renderer.js';
|
||||
|
||||
/**
|
||||
* 当前活跃的瞬态消息 DOM 引用
|
||||
*/
|
||||
let ephemeralState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示瞬态状态消息
|
||||
*
|
||||
* 同一时刻只保留一条状态消息,调用此函数会更新已有消息或创建新消息。
|
||||
*
|
||||
* @param {string} text - 状态文本
|
||||
* @param {string} [type='processing'] - 消息类型
|
||||
*/
|
||||
export function showStatus(text, type) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
if (ephemeralState.bubble) {
|
||||
ephemeralState.bubble.textContent = escapeHtml(text);
|
||||
ephemeralState.bubble.className = 'chat-bubble ' + (type || 'processing');
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'chat-message';
|
||||
wrapper.innerHTML = `
|
||||
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
|
||||
<div class="chat-bubble ${type || 'processing'}">${escapeHtml(text)}</div>
|
||||
`;
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
|
||||
ephemeralState = {
|
||||
wrapper,
|
||||
bubble: wrapper.querySelector('.chat-bubble'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除瞬态状态消息
|
||||
*/
|
||||
export function clearStatus() {
|
||||
if (ephemeralState.wrapper) {
|
||||
ephemeralState.wrapper.remove();
|
||||
}
|
||||
ephemeralState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
};
|
||||
}
|
||||
246
src/web/static/js/chat/persistent.js
Normal file
246
src/web/static/js/chat/persistent.js
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 持久消息管理器
|
||||
*
|
||||
* 持久消息永久保留在聊天历史中,包括:
|
||||
* - 普通聊天消息
|
||||
* - 文件上传消息及其状态
|
||||
* - 加载指示器
|
||||
*/
|
||||
import { App } from '../state.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { createChatBubble, appendMessage, getChatMessages, scrollToBottom } from './renderer.js';
|
||||
|
||||
// ================================================================
|
||||
// 普通聊天消息
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 添加聊天消息
|
||||
*
|
||||
* @param {string} text - 消息内容
|
||||
* @param {string} [type='processing'] - 消息类型
|
||||
* @param {boolean} [isUser=false] - 是否为用户消息
|
||||
*/
|
||||
export function addChatMessage(text, type, isUser) {
|
||||
const wrapper = createChatBubble(text, type, isUser);
|
||||
appendMessage(wrapper);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 加载指示器
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 显示加载指示器(三点动画)
|
||||
*/
|
||||
export function addTypingIndicator() {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'chat-message';
|
||||
indicator.id = 'typing-indicator';
|
||||
indicator.innerHTML = `
|
||||
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
|
||||
<div class="chat-bubble processing">
|
||||
<div class="typing-indicator"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
`;
|
||||
chatMessages.appendChild(indicator);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除加载指示器
|
||||
*/
|
||||
export function removeTypingIndicator() {
|
||||
const indicator = document.getElementById('typing-indicator');
|
||||
if (indicator) indicator.remove();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 文件消息管理
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 添加文件消息(上传文件时调用)
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
*/
|
||||
export function addFileMessage(filename) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'chat-message user file-message';
|
||||
wrapper.dataset.file = filename;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'chat-bubble file-bubble';
|
||||
|
||||
const nameRow = document.createElement('div');
|
||||
nameRow.className = 'file-name-row';
|
||||
nameRow.innerHTML = `
|
||||
<span class="file-icon"><img src="/static/icon/file.svg" alt=""></span>
|
||||
<span class="file-name">${escapeHtml(filename)}</span>
|
||||
<span class="file-status" style="display:none"></span>
|
||||
`;
|
||||
|
||||
const detailRow = document.createElement('div');
|
||||
detailRow.className = 'file-detail';
|
||||
detailRow.style.display = 'none';
|
||||
|
||||
bubble.appendChild(nameRow);
|
||||
bubble.appendChild(detailRow);
|
||||
wrapper.appendChild(bubble);
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
|
||||
App.fileMessageMap.set(filename, { wrapper, bubble, nameRow, detailRow });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文件处理中状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
*/
|
||||
export function setFileProcessing(filename) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-done', 'file-error');
|
||||
bubble.classList.add('file-processing');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline-flex';
|
||||
statusEl.innerHTML = `
|
||||
<span class="file-typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
detailRow.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件消息为完成状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
* @param {Object} summary - 提取摘要对象
|
||||
*/
|
||||
export function setFileDone(filename, summary) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-processing');
|
||||
bubble.classList.add('file-done');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline';
|
||||
statusEl.innerHTML = '<span class="file-status-icon">OK</span>';
|
||||
|
||||
const labelMap = {
|
||||
card_date: '刷卡日期',
|
||||
person_id: '人员编号',
|
||||
person_name: '人员姓名',
|
||||
invoice_type_label: '类型',
|
||||
invoice_number: '发票号码',
|
||||
invoice_date: '开票日期',
|
||||
ride_date: '乘车日期',
|
||||
departure: '出发站',
|
||||
arrival: '到达站',
|
||||
seat_class: '座位等级',
|
||||
train_no: '车次',
|
||||
total_amount: '金额',
|
||||
amount: '金额',
|
||||
seller_name: '销售方',
|
||||
buyer_name: '购买方',
|
||||
goods_name: '货物/服务',
|
||||
remark: '备注',
|
||||
card_no: '卡号',
|
||||
card_amount: '刷卡金额',
|
||||
pay_date: '支付日期',
|
||||
pay_time: '支付时间',
|
||||
pay_channel: '支付渠道',
|
||||
transaction_no: '交易单号',
|
||||
merchant_name: '商户名称',
|
||||
order_no: '订单号',
|
||||
project_name: '项目名称',
|
||||
purpose: '出差事由',
|
||||
start_date: '开始日期',
|
||||
end_date: '结束日期',
|
||||
person_info: '随行人员',
|
||||
travel_purpose: '出差事由',
|
||||
travel_from: '出发地',
|
||||
travel_to: '目的地',
|
||||
travel_start: '出发日期',
|
||||
travel_end: '返回日期',
|
||||
departure_place: '出发地',
|
||||
arrival_place: '目的地',
|
||||
hotel_name: '酒店名称',
|
||||
checkin_date: '入住日期',
|
||||
checkout_date: '退房日期',
|
||||
room_type: '房型',
|
||||
days: '天数',
|
||||
attachments: '附件',
|
||||
subsidy_list: '补助清单',
|
||||
};
|
||||
|
||||
let lines = [];
|
||||
for (const [key, value] of Object.entries(summary)) {
|
||||
const label = labelMap[key] || key;
|
||||
const displayValue = escapeHtml(String(value)).replace(/\n/g, '<br>');
|
||||
lines.push(`<span class="detail-label">${label}:</span><span class="detail-value">${displayValue}</span>`);
|
||||
}
|
||||
|
||||
detailRow.innerHTML = lines.join('<br>');
|
||||
detailRow.style.display = 'block';
|
||||
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件消息为缓存状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
*/
|
||||
export function setFileCached(filename) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-processing');
|
||||
bubble.classList.add('file-done');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline';
|
||||
statusEl.innerHTML = '<span class="file-status-icon">CACHE</span>';
|
||||
|
||||
detailRow.innerHTML = '<span class="detail-label">状态:</span><span class="detail-value">使用缓存(已提取)</span>';
|
||||
detailRow.style.display = 'block';
|
||||
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件消息为错误状态
|
||||
*
|
||||
* @param {string} filename - 文件名
|
||||
* @param {string} [errorMsg] - 错误信息
|
||||
*/
|
||||
export function setFileError(filename, errorMsg) {
|
||||
const entry = App.fileMessageMap.get(filename);
|
||||
if (!entry) return;
|
||||
|
||||
const { bubble, nameRow, detailRow } = entry;
|
||||
bubble.classList.remove('file-processing');
|
||||
bubble.classList.add('file-error');
|
||||
|
||||
const statusEl = nameRow.querySelector('.file-status');
|
||||
statusEl.style.display = 'inline';
|
||||
statusEl.innerHTML = '<span class="file-status-icon">ERR</span>';
|
||||
|
||||
detailRow.innerHTML = `<span class="detail-label">错误:</span><span class="detail-value error-text">${escapeHtml(errorMsg || '提取失败')}</span>`;
|
||||
detailRow.style.display = 'block';
|
||||
entry.wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
64
src/web/static/js/chat/renderer.js
Normal file
64
src/web/static/js/chat/renderer.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 聊天消息渲染器
|
||||
*
|
||||
* 提供通用的气泡创建、DOM 操作和滚动功能。
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* 获取聊天消息容器
|
||||
*/
|
||||
export function getChatMessages() {
|
||||
return document.getElementById('chat-messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动聊天容器到底部
|
||||
*/
|
||||
export function scrollToBottom() {
|
||||
const chatMessages = getChatMessages();
|
||||
if (chatMessages) {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建聊天消息气泡
|
||||
*
|
||||
* @param {string} text - 消息内容
|
||||
* @param {string} type - 消息类型(影响气泡样式)
|
||||
* @param {boolean} isUser - 是否为用户消息
|
||||
* @returns {HTMLElement} wrapper 元素
|
||||
*/
|
||||
export function createChatBubble(text, type, isUser) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `chat-message${isUser ? ' user' : ''}`;
|
||||
wrapper.innerHTML = `
|
||||
<div class="ai-avatar"><img src="/static/icon/${isUser ? 'user' : 'agent'}.svg" alt=""></div>
|
||||
<div class="chat-bubble ${isUser ? 'user' : (type || 'processing')}">${escapeHtml(text)}</div>
|
||||
`;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带系统头像的气泡
|
||||
*
|
||||
* @param {string} text - 消息内容
|
||||
* @param {string} type - 消息类型
|
||||
* @returns {HTMLElement} wrapper 元素
|
||||
*/
|
||||
export function createSystemBubble(text, type) {
|
||||
return createChatBubble(text, type, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将消息追加到聊天容器
|
||||
*
|
||||
* @param {HTMLElement} wrapper - 消息 wrapper 元素
|
||||
*/
|
||||
export function appendMessage(wrapper) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
}
|
||||
199
src/web/static/js/chat/stream.js
Normal file
199
src/web/static/js/chat/stream.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* LLM 流式聊天气泡管理器
|
||||
*
|
||||
* 流式消息在处理时显示思考过程和逐字输出,结束后固化为持久消息。
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { getChatMessages, scrollToBottom } from './renderer.js';
|
||||
|
||||
/**
|
||||
* 当前活跃的 LLM 流式气泡状态
|
||||
*/
|
||||
let llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 LLM 流式事件
|
||||
*
|
||||
* @param {Object} msg - SSE 传来的 llm_stream 事件对象
|
||||
*/
|
||||
export function handleLLMStream(msg) {
|
||||
switch (msg.phase) {
|
||||
case 'start':
|
||||
_createLLMStreamBubble(msg.label || '正在分析中...');
|
||||
break;
|
||||
case 'reasoning':
|
||||
_appendLLMStreamReasoning(msg.text || '');
|
||||
break;
|
||||
case 'chunk':
|
||||
_appendLLMStreamChunk(msg.text || '');
|
||||
break;
|
||||
case 'end':
|
||||
_closeLLMStreamBubble(msg.label || '分析完成');
|
||||
break;
|
||||
case 'error':
|
||||
_errorLLMStreamBubble(msg.error || '分析失败');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 LLM 流式气泡(start 阶段)
|
||||
*/
|
||||
function _createLLMStreamBubble(label) {
|
||||
const chatMessages = getChatMessages();
|
||||
if (!chatMessages) return;
|
||||
|
||||
if (llmStreamState.wrapper) {
|
||||
_closeLLMStreamBubbleSilent();
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'chat-message';
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'chat-bubble processing llm-stream-bubble';
|
||||
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.className = 'llm-stream-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const reasoningSection = document.createElement('details');
|
||||
reasoningSection.className = 'llm-reasoning-section';
|
||||
reasoningSection.open = true;
|
||||
|
||||
const reasoningSummary = document.createElement('summary');
|
||||
reasoningSummary.className = 'llm-reasoning-summary';
|
||||
reasoningSummary.textContent = '思考过程(点击折叠)';
|
||||
|
||||
const reasoningText = document.createElement('div');
|
||||
reasoningText.className = 'llm-reasoning-text';
|
||||
reasoningText.textContent = '';
|
||||
|
||||
reasoningSection.appendChild(reasoningSummary);
|
||||
reasoningSection.appendChild(reasoningText);
|
||||
|
||||
const textEl = document.createElement('div');
|
||||
textEl.className = 'llm-stream-text';
|
||||
textEl.textContent = '';
|
||||
|
||||
bubble.appendChild(labelEl);
|
||||
bubble.appendChild(reasoningSection);
|
||||
bubble.appendChild(textEl);
|
||||
wrapper.appendChild(bubble);
|
||||
chatMessages.appendChild(wrapper);
|
||||
scrollToBottom();
|
||||
|
||||
llmStreamState = {
|
||||
wrapper,
|
||||
bubble,
|
||||
textContent: textEl,
|
||||
accumulated: '',
|
||||
reasoningContent: reasoningText,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加流式文本片段(chunk 阶段)
|
||||
*/
|
||||
function _appendLLMStreamChunk(text) {
|
||||
if (!llmStreamState.textContent) return;
|
||||
|
||||
llmStreamState.accumulated += text;
|
||||
llmStreamState.textContent.textContent = llmStreamState.accumulated;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加思考过程片段(reasoning 阶段)
|
||||
*/
|
||||
function _appendLLMStreamReasoning(text) {
|
||||
if (!llmStreamState.reasoningContent) return;
|
||||
|
||||
llmStreamState.reasoningAccumulated += text;
|
||||
llmStreamState.reasoningContent.textContent = llmStreamState.reasoningAccumulated;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息
|
||||
*/
|
||||
function _closeLLMStreamBubble(label) {
|
||||
if (!llmStreamState.bubble) return;
|
||||
|
||||
llmStreamState.bubble.classList.remove('processing');
|
||||
llmStreamState.bubble.classList.add('done');
|
||||
|
||||
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = label;
|
||||
}
|
||||
|
||||
const reasoningSection = llmStreamState.bubble.querySelector('.llm-reasoning-section');
|
||||
if (reasoningSection) {
|
||||
reasoningSection.open = false;
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默关闭流式气泡(不改变样式,用于 start 前清理)
|
||||
*/
|
||||
function _closeLLMStreamBubbleSilent() {
|
||||
if (llmStreamState.wrapper) {
|
||||
llmStreamState.wrapper.remove();
|
||||
}
|
||||
llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误状态(error 阶段)
|
||||
*/
|
||||
function _errorLLMStreamBubble(errorMsg) {
|
||||
if (!llmStreamState.bubble) return;
|
||||
|
||||
llmStreamState.bubble.classList.remove('processing');
|
||||
llmStreamState.bubble.classList.add('error');
|
||||
|
||||
const labelEl = llmStreamState.bubble.querySelector('.llm-stream-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = '分析失败';
|
||||
}
|
||||
|
||||
if (llmStreamState.textContent) {
|
||||
llmStreamState.textContent.textContent = escapeHtml(errorMsg);
|
||||
}
|
||||
|
||||
llmStreamState = {
|
||||
wrapper: null,
|
||||
bubble: null,
|
||||
textContent: null,
|
||||
accumulated: '',
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
148
src/web/static/js/config.js
Normal file
148
src/web/static/js/config.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 配置收集模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { addChatMessage, showStatus } from './chat.js';
|
||||
import { handleUserSupplement, handleForceSubmit, handleSupplementUpload } from './agent.js';
|
||||
import { startProcess } from './process.js';
|
||||
|
||||
export const CONFIG_FIELDS = {
|
||||
username: '登录账号',
|
||||
password: '登录密码',
|
||||
default_name: '报销人姓名',
|
||||
default_card_no: '公务卡号',
|
||||
default_person_id: '报销人工号',
|
||||
consumable_storage: '易耗品存放地点',
|
||||
};
|
||||
|
||||
/**
|
||||
* 静默检查:仅当配置完整且有文件时自动开始处理。
|
||||
*/
|
||||
export function checkAutoStart() {
|
||||
if (App.isProcessing) return;
|
||||
if (App.processState === 'processing') return;
|
||||
|
||||
if (App.processState === 'done') {
|
||||
const hasNewFiles = App.allFiles.length > App.lastProcessedFileCount;
|
||||
if (!hasNewFiles && !App.forceStart) return;
|
||||
App.forceStart = false;
|
||||
// done 状态下有新文件时,走 supplement 流程
|
||||
if (hasNewFiles) {
|
||||
App.isProcessing = true;
|
||||
App.processState = 'processing';
|
||||
if (App.newFilenames.length > 0) {
|
||||
const filenames = App.newFilenames;
|
||||
App.newFilenames = [];
|
||||
handleSupplementUpload(filenames);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (App.processState === 'awaiting_files') {
|
||||
if (!App.forceStart) return;
|
||||
App.forceStart = false;
|
||||
}
|
||||
|
||||
if (App.processState === 'awaiting_supplement') {
|
||||
return;
|
||||
}
|
||||
|
||||
// idle 状态下有补充文件时,走 supplement 流程
|
||||
if (App.processState === 'idle' && App.newFilenames.length > 0) {
|
||||
App.isProcessing = true;
|
||||
App.processState = 'processing';
|
||||
const filenames = App.newFilenames;
|
||||
App.newFilenames = [];
|
||||
handleSupplementUpload(filenames);
|
||||
return;
|
||||
}
|
||||
|
||||
const allKeys = Object.keys(CONFIG_FIELDS);
|
||||
const missing = allKeys.filter(k => !App.sessionConfig[k]);
|
||||
|
||||
if (missing.length) return;
|
||||
|
||||
App.pendingConfigKey = null;
|
||||
|
||||
if (!App.allFiles.length) return;
|
||||
|
||||
startProcess();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置提示:在所有文件加载完成后调用,逐个提示缺失的配置项。
|
||||
*/
|
||||
export function promptMissingConfig() {
|
||||
if (App.isProcessing) return;
|
||||
|
||||
const allKeys = Object.keys(CONFIG_FIELDS);
|
||||
const missing = allKeys.filter(k => !App.sessionConfig[k]);
|
||||
|
||||
if (missing.length) {
|
||||
if (App.pendingConfigKey === null) {
|
||||
App.pendingConfigKey = missing[0];
|
||||
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
|
||||
} else if (!missing.includes(App.pendingConfigKey)) {
|
||||
App.pendingConfigKey = missing[0];
|
||||
addChatMessage(`请输入${CONFIG_FIELDS[App.pendingConfigKey]}:`, 'system');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
App.pendingConfigKey = null;
|
||||
|
||||
if (App.allFiles.length) {
|
||||
showStatus('配置信息已完整,开始处理发票……');
|
||||
startProcess();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户输入处理入口
|
||||
*/
|
||||
export async function sendUserMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// 优先处理配置收集阶段
|
||||
if (App.pendingConfigKey) {
|
||||
App.sessionConfig[App.pendingConfigKey] = text;
|
||||
addChatMessage(text, 'user', true);
|
||||
addChatMessage(`${CONFIG_FIELDS[App.pendingConfigKey]} 已设置`, 'system');
|
||||
input.value = '';
|
||||
promptMissingConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
addChatMessage(text, 'user', true);
|
||||
input.value = '';
|
||||
|
||||
// 检查是否是强制提交指令
|
||||
if (text.includes('直接提交') || text.includes('强制提交') || text.includes('继续提交')) {
|
||||
await handleForceSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否包含文件上传指令
|
||||
if (text.includes('上传') || text.includes('补充')) {
|
||||
document.getElementById('file-input').click();
|
||||
return;
|
||||
}
|
||||
|
||||
// awaiting_supplement 状态下,将用户输入发给后端 LLM 处理
|
||||
if (App.processState === 'awaiting_supplement') {
|
||||
await handleUserSupplement(text);
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('请通过上传区补充文件,或输入"直接提交"跳过校验');
|
||||
}
|
||||
|
||||
export function handleChatKey(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendUserMessage();
|
||||
}
|
||||
}
|
||||
@@ -1,483 +1,22 @@
|
||||
let sessionId = null;
|
||||
const pdfFiles = [], imgFiles = [];
|
||||
let invoiceData = []; // 当前编辑数据 [{__row, ...fields}]
|
||||
let csvFilename = ''; // 当前 CSV 文件名
|
||||
let lastDownloadUrls = {}; // 最近一次可下载文件链接
|
||||
/**
|
||||
* 入口文件 — ES Module 入口
|
||||
*
|
||||
* 所有模块通过 import 加载,依赖顺序由 import 自动解决。
|
||||
* 需要 HTML 内联事件调用的函数挂载到 window。
|
||||
*/
|
||||
import { initDragDrop, toggleUploadPanel, handleFiles } from './upload.js';
|
||||
import { sendUserMessage, handleChatKey } from './config.js';
|
||||
import { generateQr, startSync } from './sync.js';
|
||||
import { handleForceSubmit } from './agent.js';
|
||||
|
||||
// ---- Session ----
|
||||
async function ensureSession() {
|
||||
if (sessionId) return sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
sessionId = d.session_id;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
function handleFiles(input, type) {
|
||||
const files = Array.from(input.files);
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
|
||||
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
|
||||
|
||||
files.forEach(f => {
|
||||
if (!list.find(x => x.name === f.name)) {
|
||||
f.__source = 'local'; // 标记为本地手动选择
|
||||
list.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
renderFileList(type);
|
||||
document.getElementById(zoneId).classList.add('active');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeFile(type, index) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
list.splice(index, 1);
|
||||
renderFileList(type);
|
||||
if (list.length === 0) {
|
||||
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(type) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
|
||||
box.innerHTML = list.map((f, i) =>
|
||||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- 配置上传 ----
|
||||
function handleConfigUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const cfg = JSON.parse(e.target.result);
|
||||
const map = {
|
||||
'cfg-username': cfg.username,
|
||||
'cfg-password': cfg.password,
|
||||
'cfg-name': cfg.default_name,
|
||||
'cfg-card': cfg.default_card_no,
|
||||
'cfg-person-id': cfg.default_person_id,
|
||||
'cfg-storage': cfg.consumable_storage,
|
||||
};
|
||||
for (const [id, val] of Object.entries(map)) {
|
||||
if (val) document.getElementById(id).value = val;
|
||||
}
|
||||
// 同步 config.json 中的工号和公务卡号到表格
|
||||
if (cfg.username) syncConfigToTable('工号');
|
||||
if (cfg.default_card_no) syncConfigToTable('公务卡号');
|
||||
alert('配置已加载');
|
||||
} catch (err) {
|
||||
alert('config.json 解析失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ---- 拖拽 ----
|
||||
['pdf','img'].forEach(type => {
|
||||
const zone = document.getElementById(type + '-zone');
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => {
|
||||
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
|
||||
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
|
||||
});
|
||||
if (files.length) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
files.forEach(f => {
|
||||
f.__source = 'local'; // 标记为本地拖拽
|
||||
if (!list.find(x => x.name === f.name)) list.push(f);
|
||||
});
|
||||
renderFileList(type);
|
||||
zone.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 处理 ----
|
||||
async function startProcess() {
|
||||
if (!pdfFiles.length && !imgFiles.length) {
|
||||
alert('请先上传文件或图片');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-start');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '处理中...';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
document.getElementById('edit-section').style.display = 'none';
|
||||
document.getElementById('download-section').style.display = 'none';
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
||||
for (const {f} of allFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||
|
||||
const cfg = {
|
||||
username: document.getElementById('cfg-username').value,
|
||||
password: document.getElementById('cfg-password').value,
|
||||
default_name: document.getElementById('cfg-name').value,
|
||||
default_card_no: document.getElementById('cfg-card').value,
|
||||
default_person_id: document.getElementById('cfg-person-id').value,
|
||||
consumable_storage: document.getElementById('cfg-storage').value,
|
||||
};
|
||||
|
||||
await fetch(`/api/process/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
// 监听 SSE 日志
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
const result = msg.result;
|
||||
document.getElementById('status').innerHTML = result.ok
|
||||
? '<span class="badge bg-success status-badge">完成</span>'
|
||||
: '<span class="badge bg-danger status-badge">失败</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
|
||||
if (result.ok) {
|
||||
showDownloadLinks(result);
|
||||
loadInvoiceData(); // 加载可编辑数据
|
||||
} else {
|
||||
alert('处理失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 下载链接 ----
|
||||
function showDownloadLinks(result) {
|
||||
const section = document.getElementById('download-section');
|
||||
const box = document.getElementById('download-links');
|
||||
const warn = document.getElementById('doc-fill-warning');
|
||||
if (!section || !box) return;
|
||||
|
||||
const items = [];
|
||||
if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url });
|
||||
if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url });
|
||||
|
||||
lastDownloadUrls = {};
|
||||
items.forEach(it => { lastDownloadUrls[it.label] = it.url; });
|
||||
|
||||
box.innerHTML = items.map(it =>
|
||||
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
|
||||
).join('');
|
||||
|
||||
if (warn) {
|
||||
if (result.doc_skipped) {
|
||||
warn.style.display = 'block';
|
||||
warn.style.color = '#0d6efd';
|
||||
warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单';
|
||||
} else if (result.doc_ok === false && result.doc_error) {
|
||||
warn.style.display = 'block';
|
||||
warn.style.color = '';
|
||||
warn.textContent = '出库单未生成:' + result.doc_error;
|
||||
} else {
|
||||
warn.style.display = 'none';
|
||||
warn.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ---- 发票数据编辑 ----
|
||||
async function loadInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/data/${sessionId}`);
|
||||
const d = await r.json();
|
||||
if (d.error) return;
|
||||
|
||||
csvFilename = d.csv_filename || 'invoice_summary.csv';
|
||||
invoiceData = d.data || [];
|
||||
const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__'));
|
||||
renderTable(invoiceData, fields);
|
||||
|
||||
// 渲染后自动将配置区的工号/公务卡号同步到表格
|
||||
syncConfigToTable('工号');
|
||||
syncConfigToTable('公务卡号');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 配置区 → 表格的同步映射:工号 ↔ cfg-username,公务卡号 ↔ cfg-card
|
||||
const CONFIG_SYNC = {
|
||||
'工号': 'cfg-username',
|
||||
'公务卡号': 'cfg-card',
|
||||
};
|
||||
|
||||
// 从配置区输入框的值更新到所有表格行
|
||||
function syncConfigToTable(field) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (!inputId) return;
|
||||
const val = document.getElementById(inputId).value || '';
|
||||
invoiceData.forEach((row, i) => {
|
||||
row[field] = val;
|
||||
});
|
||||
// 更新表格中对应单元格的显示
|
||||
const tbody = document.getElementById('invoice-tbody');
|
||||
if (!tbody) return;
|
||||
const rows = tbody.querySelectorAll('tr');
|
||||
rows.forEach((tr, i) => {
|
||||
const inputs = tr.querySelectorAll('input');
|
||||
fieldsCache.forEach((f, colIdx) => {
|
||||
if (f === field && inputs[colIdx]) {
|
||||
inputs[colIdx].value = val;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引
|
||||
|
||||
function renderTable(data, fields) {
|
||||
const section = document.getElementById('edit-section');
|
||||
if (!data.length) { section.style.display = 'none'; return; }
|
||||
section.style.display = 'block';
|
||||
|
||||
// 使用后端返回的字段顺序, fallback 到 Object.keys
|
||||
if (!fields || !fields.length) {
|
||||
fields = Object.keys(data[0]).filter(k => !k.startsWith('__'));
|
||||
}
|
||||
fieldsCache = fields;
|
||||
|
||||
// 表头
|
||||
document.getElementById('invoice-thead').innerHTML = `
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
${fields.map(f => `<th>${f}</th>`).join('')}
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 表体:每行首列为序号,其后为各字段 input
|
||||
const rows = data.map((row, i) => {
|
||||
const cells = fields.map(f => {
|
||||
let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`;
|
||||
// 如果该字段参与配置区同步,额外调用 syncTableToConfig
|
||||
if (CONFIG_SYNC[f]) {
|
||||
onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`;
|
||||
}
|
||||
return `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
|
||||
onchange="${onChangeStr}"></td>`;
|
||||
}).join('');
|
||||
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('invoice-tbody').innerHTML = rows;
|
||||
}
|
||||
|
||||
// 从表格修改同步回配置区
|
||||
function syncTableToConfig(field, value) {
|
||||
const inputId = CONFIG_SYNC[field];
|
||||
if (inputId) {
|
||||
document.getElementById(inputId).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 将当前表格编辑内容写回服务器(内部调用)
|
||||
async function saveInvoiceData() {
|
||||
try {
|
||||
const r = await fetch(`/api/save/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.doc_url || d.doc_ok === false) {
|
||||
showDownloadLinks({
|
||||
csv_url: lastDownloadUrls['invoice_summary.csv'],
|
||||
doc_url: d.doc_url,
|
||||
doc_ok: d.doc_ok,
|
||||
doc_error: d.doc_error,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('自动保存失败,继续提交:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 提交到财务系统 ----
|
||||
async function submitFinancial() {
|
||||
const btn = document.getElementById('btn-submit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '提交中...';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
|
||||
try {
|
||||
// 提交前先自动保存当前表格编辑内容
|
||||
await saveInvoiceData();
|
||||
|
||||
await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' });
|
||||
|
||||
// 监听日志流(复用 SSE)
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
const result = msg.result;
|
||||
if (result && result.submit_ok) {
|
||||
btn.textContent = '✅ 提交完成';
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000);
|
||||
} else {
|
||||
const errorMsg = result?.submit_error || result?.error || '未知错误';
|
||||
btn.textContent = '❌ 提交失败';
|
||||
logBox.innerHTML += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
console.warn('提交失败:', errorMsg);
|
||||
setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
};
|
||||
} catch (e) {
|
||||
alert('提交失败: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 提交到财务系统';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 二维码 / 手机扫码上传 ----
|
||||
let qrGenerated = false;
|
||||
let syncTimer = null;
|
||||
|
||||
async function generateQr() {
|
||||
await ensureSession();
|
||||
const mobileUrl = window.location.origin + '/mobile/' + sessionId;
|
||||
const box = document.getElementById('qrcode');
|
||||
box.innerHTML = '';
|
||||
new QRCode(box, {
|
||||
text: mobileUrl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
colorDark: '#333',
|
||||
colorLight: '#fff',
|
||||
});
|
||||
qrGenerated = true;
|
||||
}
|
||||
|
||||
// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ----
|
||||
async function startSync() {
|
||||
if (syncTimer) return;
|
||||
await syncFiles();
|
||||
syncTimer = setInterval(syncFiles, 3000);
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
|
||||
}
|
||||
|
||||
async function syncFiles() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch(`/api/files/${sessionId}`);
|
||||
const d = await r.json();
|
||||
const serverNames = new Set(d.images || []);
|
||||
const localNames = new Set(imgFiles.map(f => f.name));
|
||||
|
||||
for (const name of serverNames) {
|
||||
if (!localNames.has(name)) {
|
||||
const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`);
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], name, { type: blob.type });
|
||||
file.__source = 'server'; // 标记为扫码上传
|
||||
imgFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// 只清理来自服务器但已不存在的文件,保留本地手动选择的文件
|
||||
for (const f of [...imgFiles]) {
|
||||
if (!serverNames.has(f.name) && f.__source !== 'local') {
|
||||
const idx = imgFiles.indexOf(f);
|
||||
if (idx > -1) imgFiles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
renderFileList('img');
|
||||
if (imgFiles.length) {
|
||||
document.getElementById('img-zone').classList.add('active');
|
||||
} else {
|
||||
document.getElementById('img-zone').classList.remove('active');
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// 将需要 HTML 内联事件调用的函数挂载到 window
|
||||
window.handleChatKey = handleChatKey;
|
||||
window.sendUserMessage = sendUserMessage;
|
||||
window.initDragDrop = initDragDrop;
|
||||
window.toggleUploadPanel = toggleUploadPanel;
|
||||
window.handleFiles = handleFiles;
|
||||
window.handleForceSubmit = handleForceSubmit;
|
||||
|
||||
initDragDrop();
|
||||
generateQr();
|
||||
startSync();
|
||||
|
||||
// ---- 配置区 → 表格的双向同步绑定 ----
|
||||
Object.values(CONFIG_SYNC).forEach(inputId => {
|
||||
const el = document.getElementById(inputId);
|
||||
if (el) {
|
||||
el.addEventListener('input', () => {
|
||||
// 根据 inputId 反查对应的字段名
|
||||
const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0];
|
||||
if (field) syncConfigToTable(field);
|
||||
});
|
||||
}
|
||||
});
|
||||
startSync();
|
||||
148
src/web/static/js/process.js
Normal file
148
src/web/static/js/process.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 发票处理模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { ensureSession } from './utils.js';
|
||||
import {
|
||||
addTypingIndicator,
|
||||
removeTypingIndicator,
|
||||
setFileProcessing,
|
||||
setFileDone,
|
||||
setFileCached,
|
||||
setFileError,
|
||||
handleLLMStream,
|
||||
addChatMessage,
|
||||
showStatus,
|
||||
} from './chat.js';
|
||||
import { handleAgentEvent, handleAutoSubmit } from './agent.js';
|
||||
|
||||
export async function startProcess() {
|
||||
if (!App.allFiles.length) {
|
||||
alert('请先上传文件或图片');
|
||||
return;
|
||||
}
|
||||
|
||||
App.isProcessing = true;
|
||||
App.processState = 'processing';
|
||||
|
||||
addTypingIndicator();
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
for (const f of App.allFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
removeTypingIndicator();
|
||||
addTypingIndicator();
|
||||
|
||||
const cfg = {
|
||||
username: App.sessionConfig.username || '',
|
||||
password: App.sessionConfig.password || '',
|
||||
default_name: App.sessionConfig.default_name || '',
|
||||
default_card_no: App.sessionConfig.default_card_no || '',
|
||||
default_person_id: App.sessionConfig.default_person_id || '',
|
||||
consumable_storage: App.sessionConfig.consumable_storage || '',
|
||||
};
|
||||
|
||||
await fetch(`/api/agent/process/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
App.agentEventSource = es;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type === 'file_progress') {
|
||||
const filename = msg.file;
|
||||
switch (msg.status) {
|
||||
case 'processing':
|
||||
setFileProcessing(filename);
|
||||
break;
|
||||
case 'done':
|
||||
setFileDone(filename, msg.summary || {});
|
||||
break;
|
||||
case 'cached':
|
||||
setFileCached(filename);
|
||||
break;
|
||||
case 'error':
|
||||
setFileError(filename, msg.error);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'llm_stream') {
|
||||
handleLLMStream(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type && msg.type.startsWith('agent_')) {
|
||||
handleAgentEvent(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.agentEventSource = null;
|
||||
removeTypingIndicator();
|
||||
const result = msg.result;
|
||||
App.isProcessing = false;
|
||||
|
||||
if (App.forceSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.waiting_for_supplement) {
|
||||
App.processState = 'awaiting_supplement';
|
||||
showStatus('信息不完整,请补充上传相关材料');
|
||||
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
|
||||
} else {
|
||||
App.processState = 'done';
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
|
||||
if (result.ok) {
|
||||
if (result.agent_ready) {
|
||||
if (result.submit_ok !== false) {
|
||||
addChatMessage('信息完整,已自动提交到财务系统', 'done');
|
||||
} else {
|
||||
addChatMessage(`校验通过但提交失败:${result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
} else if (result.invoice_count) {
|
||||
addChatMessage(`处理完成!共提取 ${result.invoice_count} 张发票数据`, 'done');
|
||||
}
|
||||
} else {
|
||||
addChatMessage(`处理失败:${result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志行
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
removeTypingIndicator();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断,请刷新页面重试', 'error');
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
removeTypingIndicator();
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
App.isProcessing = false;
|
||||
App.processState = 'idle';
|
||||
addChatMessage(`请求失败:${e.message || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
24
src/web/static/js/state.js
Normal file
24
src/web/static/js/state.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 全局状态管理
|
||||
*
|
||||
* 所有模块通过 App 对象共享状态,避免全局变量污染。
|
||||
*/
|
||||
export const App = {
|
||||
sessionId: null,
|
||||
allFiles: [],
|
||||
sessionConfig: {},
|
||||
invoiceData: [],
|
||||
csvFilename: '',
|
||||
lastDownloadUrls: {},
|
||||
isProcessing: false,
|
||||
pendingConfigKey: null,
|
||||
fieldsCache: [],
|
||||
syncTimer: null,
|
||||
fileMessageMap: new Map(),
|
||||
processState: 'idle',
|
||||
newFilenames: [],
|
||||
lastProcessedFileCount: 0,
|
||||
forceStart: false,
|
||||
agentEventSource: null,
|
||||
forceSubmitting: false,
|
||||
};
|
||||
80
src/web/static/js/sync.js
Normal file
80
src/web/static/js/sync.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 移动端同步模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { ensureSession } from './utils.js';
|
||||
import { addFileMessage } from './chat.js';
|
||||
import { checkAutoStart, promptMissingConfig } from './config.js';
|
||||
|
||||
export async function generateQr() {
|
||||
await ensureSession();
|
||||
const mobileUrl = window.location.origin + '/mobile/' + App.sessionId;
|
||||
const box = document.getElementById('qrcode');
|
||||
box.innerHTML = '';
|
||||
new QRCode(box, {
|
||||
text: mobileUrl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
colorDark: '#333',
|
||||
colorLight: '#fff',
|
||||
});
|
||||
}
|
||||
|
||||
export async function startSync() {
|
||||
if (App.syncTimer) return;
|
||||
await syncFiles();
|
||||
App.syncTimer = setInterval(syncFiles, 3000);
|
||||
}
|
||||
|
||||
export function stopSync() {
|
||||
if (App.syncTimer) { clearInterval(App.syncTimer); App.syncTimer = null; }
|
||||
}
|
||||
|
||||
async function syncFiles() {
|
||||
if (!App.sessionId) return;
|
||||
try {
|
||||
const r = await fetch(`/api/files/${App.sessionId}`);
|
||||
const d = await r.json();
|
||||
const serverFiles = d.files || [];
|
||||
const serverNames = new Set(serverFiles.map(f => f.name));
|
||||
const localNames = new Set(App.allFiles.map(f => f.name));
|
||||
const addedFiles = [];
|
||||
|
||||
for (const serverFile of serverFiles) {
|
||||
if (!localNames.has(serverFile.name)) {
|
||||
const resp = await fetch(`/api/download/${App.sessionId}/${encodeURIComponent(serverFile.name)}`);
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], serverFile.name, { type: blob.type });
|
||||
file.__source = 'server';
|
||||
App.allFiles.push(file);
|
||||
addedFiles.push(serverFile.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of [...App.allFiles]) {
|
||||
if (!serverNames.has(f.name) && f.__source !== 'local') {
|
||||
const idx = App.allFiles.indexOf(f);
|
||||
if (idx > -1) App.allFiles.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (addedFiles.length) {
|
||||
addedFiles.forEach(name => addFileMessage(name));
|
||||
if (App.processState === 'done') {
|
||||
App.processState = 'idle';
|
||||
App.newFilenames = addedFiles;
|
||||
} else if (App.processState === 'awaiting_supplement') {
|
||||
App.processState = 'idle';
|
||||
App.newFilenames = addedFiles;
|
||||
}
|
||||
}
|
||||
|
||||
if (App.allFiles.length) {
|
||||
document.getElementById('file-zone').classList.add('active');
|
||||
} else {
|
||||
document.getElementById('file-zone').classList.remove('active');
|
||||
}
|
||||
|
||||
checkAutoStart();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
141
src/web/static/js/upload.js
Normal file
141
src/web/static/js/upload.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 文件上传模块
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import { addFileMessage, addChatMessage } from './chat.js';
|
||||
import { promptMissingConfig, checkAutoStart } from './config.js';
|
||||
import { handleSupplementUpload, hideAgentRequest } from './agent.js';
|
||||
import { ensureSession } from './utils.js';
|
||||
|
||||
export async function handleFiles(input) {
|
||||
const files = Array.from(input.files);
|
||||
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
|
||||
let hasNewFiles = false;
|
||||
const newFilenames = [];
|
||||
|
||||
for (const f of files) {
|
||||
if (/\.json$/i.test(f.name)) {
|
||||
await parseConfigFile(f);
|
||||
continue;
|
||||
}
|
||||
if (!App.allFiles.find(x => x.name === f.name) && validExts.test(f.name)) {
|
||||
f.__source = 'local';
|
||||
App.allFiles.push(f);
|
||||
document.getElementById('file-zone').classList.add('active');
|
||||
addFileMessage(f.name);
|
||||
hasNewFiles = true;
|
||||
newFilenames.push(f.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewFiles && App.processState === 'done') {
|
||||
App.processState = 'idle';
|
||||
App.newFilenames = newFilenames;
|
||||
}
|
||||
if (hasNewFiles && App.processState === 'awaiting_supplement') {
|
||||
hideAgentRequest();
|
||||
await _uploadNewFiles(newFilenames);
|
||||
App.newFilenames = newFilenames;
|
||||
App.processState = 'idle';
|
||||
checkAutoStart();
|
||||
return;
|
||||
}
|
||||
|
||||
promptMissingConfig();
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function _uploadNewFiles(filenames) {
|
||||
await ensureSession();
|
||||
for (const f of App.allFiles) {
|
||||
if (filenames.includes(f.name)) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseConfigFile(file) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
try {
|
||||
const cfg = JSON.parse(e.target.result);
|
||||
Object.assign(App.sessionConfig, {
|
||||
username: cfg.username || '',
|
||||
password: cfg.password || '',
|
||||
default_name: cfg.default_name || '',
|
||||
default_card_no: cfg.default_card_no || '',
|
||||
default_person_id: cfg.default_person_id || '',
|
||||
consumable_storage: cfg.consumable_storage || '',
|
||||
});
|
||||
addChatMessage(`config.json 已加载`, 'system');
|
||||
resolve(true);
|
||||
} catch (err) {
|
||||
console.warn('config.json 解析失败:', err.message);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function initDragDrop() {
|
||||
const zone = document.getElementById('file-zone');
|
||||
if (!zone) return;
|
||||
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', async e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
let hasNewFiles = false;
|
||||
const newFilenames = [];
|
||||
|
||||
for (const f of droppedFiles) {
|
||||
if (/\.json$/i.test(f.name)) {
|
||||
await parseConfigFile(f);
|
||||
continue;
|
||||
}
|
||||
if (validExts.test(f.name) && !App.allFiles.find(x => x.name === f.name)) {
|
||||
f.__source = 'local';
|
||||
App.allFiles.push(f);
|
||||
zone.classList.add('active');
|
||||
addFileMessage(f.name);
|
||||
hasNewFiles = true;
|
||||
newFilenames.push(f.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewFiles && App.processState === 'done') {
|
||||
App.processState = 'idle';
|
||||
}
|
||||
|
||||
// 在 awaiting_supplement 状态下,只上传新文件并走 supplement 流程
|
||||
if (hasNewFiles && App.processState === 'awaiting_supplement') {
|
||||
hideAgentRequest();
|
||||
await _uploadNewFiles(newFilenames);
|
||||
App.newFilenames = newFilenames;
|
||||
App.processState = 'idle';
|
||||
checkAutoStart();
|
||||
return;
|
||||
}
|
||||
|
||||
promptMissingConfig();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换上传面板折叠/展开状态
|
||||
*/
|
||||
export function toggleUploadPanel() {
|
||||
const body = document.getElementById('upload-panel-body');
|
||||
const icon = document.getElementById('upload-toggle-icon');
|
||||
if (!body || !icon) return;
|
||||
|
||||
body.classList.toggle('collapsed');
|
||||
icon.classList.toggle('collapsed');
|
||||
}
|
||||
18
src/web/static/js/utils.js
Normal file
18
src/web/static/js/utils.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 工具函数模块
|
||||
*
|
||||
* 提供 HTML 转义和会话管理等基础功能。
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return s.replace(/\&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export async function ensureSession() {
|
||||
if (App.sessionId) return App.sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
App.sessionId = d.session_id;
|
||||
return App.sessionId;
|
||||
}
|
||||
@@ -9,119 +9,49 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header text-center mb-4">
|
||||
<h3>财务报销自动化</h3>
|
||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、LLM 识别并填报</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:960px">
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
|
||||
<div class="icon">📁</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
|
||||
<div id="pdf-list" class="mt-2"></div>
|
||||
<div class="main-container" style="max-width:960px">
|
||||
<!-- 聊天窗口 -->
|
||||
<div class="chat-container" id="chat-box">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="chat-welcome">
|
||||
<div class="ai-avatar"><img src="/static/icon/agent.svg" alt=""></div>
|
||||
<div class="chat-bubble system">你好!我是财务报销助手。请先上传 <b>config.json</b> 配置文件,然后上传发票 PDF 或支付截图,我将自动为您处理。</div>
|
||||
</div>
|
||||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
|
||||
<div class="icon">🖼️</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
|
||||
<div id="img-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<div class="section-title">📱 手机扫码上传</div>
|
||||
<div id="qrcode" style="display:inline-block"></div>
|
||||
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
|
||||
<div class="chat-input-area">
|
||||
<input type="text" id="chat-input" placeholder="输入消息..." onkeydown="handleChatKey(event)" />
|
||||
<button class="btn btn-primary" onclick="sendUserMessage()">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="section-title">⚙️ 配置
|
||||
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
|
||||
📤 上传 config.json
|
||||
</span>
|
||||
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
|
||||
<!-- 上传区域(可折叠) -->
|
||||
<div class="upload-panel">
|
||||
<div class="upload-panel-header" onclick="toggleUploadPanel()">
|
||||
<span><img src="/static/icon/file.svg" alt="" style="width:14px;height:14px;vertical-align:middle;margin-right:4px">文件上传</span>
|
||||
<span class="upload-panel-toggle" id="upload-toggle-icon">▼</span>
|
||||
</div>
|
||||
<div class="upload-panel-body" id="upload-panel-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="upload-zone" id="file-zone" onclick="event.stopPropagation(); document.getElementById('file-input').click()">
|
||||
<div class="icon"><img src="/static/icon/folder.svg" alt=""></div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传文件(支持 PDF、图片)</div>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept=".pdf,image/*,.json" multiple hidden onchange="handleFiles(this)">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
|
||||
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
|
||||
<div class="col-md-4 text-center">
|
||||
<div class="section-title"><img src="/static/icon/phone.svg" alt="" style="width:16px;height:16px;vertical-align:middle;margin-right:4px">手机扫码上传</div>
|
||||
<div id="qrcode" style="display:inline-block"></div>
|
||||
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="text-center mb-4">
|
||||
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
|
||||
<span id="status" class="ms-3"></span>
|
||||
</div>
|
||||
|
||||
<!-- 下载区 -->
|
||||
<div class="card mb-4" id="download-section" style="display:none">
|
||||
<div class="card-body py-3">
|
||||
<div class="section-title mb-2">📥 下载文件</div>
|
||||
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
|
||||
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发票数据编辑区 -->
|
||||
<div class="card mb-4" id="edit-section" style="display:none">
|
||||
<div class="card-body">
|
||||
<div class="section-title d-flex justify-content-between align-items-center">
|
||||
<span>📝 付款记录(可编辑)</span>
|
||||
<div class="d-flex justify-content-end align-items-center">
|
||||
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
|
||||
<thead id="invoice-thead"></thead>
|
||||
<tbody id="invoice-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="section-title">📋 处理日志</div>
|
||||
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<script src="/static/js/index.js"></script>
|
||||
<script type="module" src="/static/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,7 +25,7 @@ body { background: #f5f7fa; padding-bottom: 40px; }
|
||||
{% else %}
|
||||
|
||||
<div class="header">
|
||||
<h5>📸 支付截图上传</h5>
|
||||
<h5>支付截图上传</h5>
|
||||
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,8 @@ body { background: #f5f7fa; padding-bottom: 40px; }
|
||||
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
|
||||
|
||||
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
|
||||
📷 选择图片 / 拍照
|
||||
<img src="/static/icon/gallery.svg" alt="" style="width:20px;height:20px;vertical-align:middle;margin-right:6px">
|
||||
选择图片 / 拍照
|
||||
</button>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
@@ -99,7 +100,7 @@ async function handleSelect(input) {
|
||||
}
|
||||
|
||||
if (done === files.length) {
|
||||
status.innerHTML = `<div class="text-success">✅ ${done} 张图片上传成功</div>`;
|
||||
status.innerHTML = `<div class="text-success">${done} 张图片上传成功</div>`;
|
||||
} else {
|
||||
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user