更新
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechat-rpa": {
|
||||
"command": "python",
|
||||
"args": ["D:/web/age/wechat_rpa/mcp_server.py"],
|
||||
"cwd": "D:/web/age/wechat_rpa"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
**
|
||||
!*.py
|
||||
!deploy/
|
||||
!deploy/im-admin/
|
||||
!deploy/im-admin/Dockerfile
|
||||
!deploy/im-admin/requirements.txt
|
||||
!admin-web/
|
||||
!admin-web/apps/
|
||||
!admin-web/apps/web-antd/
|
||||
!admin-web/apps/web-antd/dist/
|
||||
!admin-web/apps/web-antd/dist/**
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
.edge-headless/
|
||||
kf-preview.png
|
||||
__pycache__/
|
||||
*.pyc
|
||||
backend.db
|
||||
backend.db-shm
|
||||
backend.db-wal
|
||||
backend_connection.json
|
||||
backend_connection.json.tmp
|
||||
backend_runtime.json
|
||||
backend_runtime.json.tmp
|
||||
ai_agent_override.json
|
||||
ai_agent_override.json.tmp
|
||||
backend_deploy/.env
|
||||
backend_deploy/wechat-backend-baota.zip
|
||||
.build-venv/
|
||||
build/
|
||||
dist/
|
||||
.installer-tools/
|
||||
dsh_desk/
|
||||
@@ -0,0 +1,221 @@
|
||||
# 2026-08-21 工作日志
|
||||
|
||||
## 企业微信自动回复第二套方案(引擎 B 数据直读)落地
|
||||
|
||||
### 背景
|
||||
用户确认 4 个拍板点:发送层 GUI 复用、数据来源两条线并行、机器人对接复用 ai_chat、互斥共存。
|
||||
|
||||
### 新建文件
|
||||
- `send_lock.py`:发送互斥锁。Windows 命名互斥量(`Global\WeChatBotSendLock`)+ 线程持有者跟踪(防同线程重入);非 Windows 回退 threading.Lock。`try_acquire(owner, timeout)` / `release()` / `lock()` 上下文管理器。
|
||||
- `engine_b.py`:引擎 B(数据直读)。`DataEngine` 类独立线程轮询 `conversations.json`,按 history 增量发现新客户消息,经 `bot.enqueue_detected()` 投递共享队列。启动时 120s 内新消息算新鲜可投,防抖窗口 300s。纯检测纯投递,不碰窗口。
|
||||
- `test_dual_engine.py`:双引擎集成测试(31 项断言),覆盖互斥锁/投递/字段透传/去重/合并/DataEngine 集成。回归命令:系统 Python 3.11 跑(wechat_bot 依赖 win32gui,managed python 无此包)。
|
||||
|
||||
### 修改文件
|
||||
- `wechat_bot.py`:
|
||||
- 队列条目新增 5 字段:`dedup_key`/`detected_by`/`detection_ts`/`sending_owner`/`send_lock_ts`,在 `_load_pending_replies` 与 `_persist_pending_replies_unlocked` 两处白名单透传(旧条目自动补默认值,44 字段兼容验证通过)。
|
||||
- `send_reply()` 改为互斥包装:`try_acquire` 拿不到锁 → `_deny_send("另一引擎正在发送")`;原主体改名 `_send_reply_inner`。
|
||||
- 新增 `enqueue_detected()`(B 引擎投递,复用 `_mark_reply_pending` + 补新字段 + display_name 兜底)、`has_active_pending()`(终态集合判断去重)、`merge_detected_by()`、`_merge_engine_tags()`。
|
||||
- `gui_runtime.py`:`BotThread.__init__` 加 `enable_engine_b`/`engine_b_poll_interval`;`run()` 中 bot connect 成功后启动 DataEngine 线程,finally/stop 中停止。
|
||||
- `wechat_gui_qt.py` / `wechat_gui.py`:runtime 默认值加 `enable_engine_b=True`、`engine_b_poll_interval=2.0`,start_monitoring 传参。
|
||||
- `app_settings.json`:加 `enable_engine_b: true`、`engine_b_poll_interval: 2.0`。
|
||||
|
||||
### 关键架构决策
|
||||
- **B 引擎不自己发送**:队列条目由引擎 A 的 `_resume_orphaned_pending_reply` 统一捡起(重新定位→视觉提取→`_generate_ai_reply`→`send_reply`)。发送天然收敛到唯一通道 + 互斥锁,B 全程不碰窗口。这是"检测解耦、发送收敛"的最稳落地。
|
||||
- 检测到 A 已处理的会话时 B 只补 detected_by 标记,不重复投递。
|
||||
- 队列字段是白名单读写,新增字段必须在 load/persist 两处透传,否则 B 引擎的标记会被 A 持久化时抹掉。
|
||||
|
||||
### 验证
|
||||
- `test_dual_engine.py`:31 项全部通过。
|
||||
- `test_runtime_settings.py` 18 项、`test_session_name.py` 67 项回归通过。
|
||||
- 旧 pending_replies.json 条目加载兼容(新字段自动补默认值)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:企微本地库解密(密钥提取攻坚)
|
||||
|
||||
### 关键发现(本轮)
|
||||
1. **真正的 bug 根因**:`wxwork_key.py::_prep_verify` 的验证基准构造错误——读了文件 offset 16..32(含明文头),正确应为 `8..16 密文A + 24..32 密文B`(与 `wxwork_crypto.decrypt_page1_block` 一致)。修复后探针测试从"0 命中"变为"命中"。
|
||||
2. **算法确认**:`WXWork.exe`(269MB,`D:\soft\WXWork\`,版本 5.0.10.6015)代码段含 `sAlT` 常量 + MD5 初始常量 `0x67452301` 的机器码(`mov [ebp-0xC],0x546C4173` / `mov [ebp-0x7C],0x67452301`)→ 证实 wxSQLite3 aes128cbc MD5+sAlT 派生算法判断正确。
|
||||
3. **扫描器性能优化**:`regions(rw_only=True)` 只扫可写区 + `len(set(w))>=8` 字节多样性预过滤 + `_fast_verify` 单变体优先验证(AES 3→1 次)。探针扫描 87.7s → 3.4s。
|
||||
4. **扫描覆盖全部**:16/32 字节窗口 × 16/8 字节对齐 × 全部企微进程(含 WeChatAppEx.exe 8 实例、WXDrive_x64.exe、WXWorkXNet.exe),3 轮全 0 命中 → **结论:企微 5.0.10 数据库密钥不在常驻可读内存**(用完即焚/受保护页/混淆存储)。
|
||||
5. 当前进程非管理员权限(IsUserAnAdmin=False)。
|
||||
|
||||
### 新建/修改文件
|
||||
- `wxwork_key.py`:修复 `_prep_verify`;`regions()` 加 `rw_only` 参数;`scan_binary_single` 加 `key_len`/`step` 参数;`scan_binary_all` 支持 step;`find_wxwork_pids` 加 `include_extra`(WeChatAppEx/WXDrive);CLI 加 `--binary8`(8 字节对齐)。
|
||||
- `test_scan_probe.py`:修复子进程 code 路径 `\U` 转义(用 `!r`),加子进程存活检查;改为 aligned 优先。
|
||||
- `test_scan_probe_mis.py`(新):非对齐密钥探针,验证全滑窗兜底。
|
||||
- `scan_golden_window.py`(新):重启企微黄金窗口多轮扫描脚本(`--kill` 需用户确认)。
|
||||
- `probe_wxwork_db.py`:已就绪(等密钥到手探查 message.db 表结构)。
|
||||
|
||||
### 下一步(待用户决策)
|
||||
- A. 黄金窗口扫描:重启企微后立即多轮扫(`scan_golden_window.py`,需用户同意重启)
|
||||
- B. 管理员权限重跑全部扫描
|
||||
- C. hook sqlite3_key(看雪方案,注入拦截,开发量大)
|
||||
- D. 换会话存档 SDK 路线(`Downloads\wxwork-finance-sdk-main`,企业后台 API)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 落地:参考目录代码 → DB 直读(密钥已验证,主线打通)
|
||||
|
||||
### 背景
|
||||
用户提供参考目录 `C:\Users\pc\WorkBuddy\2026-08-19-18-27-34\`(企业微信导出助手,密钥已验证),要求以它为基准实现 DB 直读,保留密钥逻辑、复用可靠模块。
|
||||
|
||||
### 参考目录关键资产(已逐文件分析)
|
||||
- `wxwork_keys.json`:2 个账号验证过密钥(`1688856770803435`/`db7288a302bfbe66563d81f025bde977`、`1688855555743233`/`d18e7f9a222665389989066f929ece19`)→ **密钥来源问题解决,内存提取不再必要**
|
||||
- `wxwork_crypto.py`:解密算法与本项目 `wxwork_crypto.py` 完全一致(互相印证)
|
||||
- `wxwork_find_key.py`:7 种内存密钥提取模式(参考项目曾在运行中企微成功提取,但本项目 5.0.10 提取不到)
|
||||
- `wxwork_export_final.py`:`parse_content`(UTF-8→protobuf 递归→JSON→hex)、`MSG_TYPE_MAP`、`is_personal_chat`(M:/S: 白名单)、`clean_text`、`format_timestamp`、`load_keys`(支持 `*` 通配)、`decrypt_with_keys`(WAL 感知增量缓存)、`detect_wxwork_dir`、会话名解析
|
||||
- `wxwork_export_db.py`:`collect_metadata`(user_table/conversation_table → user_cache/conv_cache)
|
||||
- `crm_import/decrypted/`:9 账号目录全空 → 印证仅 2 账号有密钥
|
||||
|
||||
### 新建/修改文件
|
||||
- `wxwork_keys.json`(新):复制参考密钥,含 note 说明"每账号独立密钥,未列出账号未登录"
|
||||
- `wxwork_db.py`(重写):
|
||||
- 移植参考全套:`load_keys`/`decrypt_with_keys`/`parse_content`/`extract_protobuf_texts`/`clean_text`/`MSG_TYPE_MAP`/`get_msg_type_name`/`is_personal_chat`/`is_blocked_conv_name`/`format_timestamp`/`detect_wxwork_dir`/`connect_sqlite`/会话名解析(user_cache/conv_cache/S: 对端推导)
|
||||
- **WAL 帧合并增强**(参考实现没有):企微运行时新消息在 message.db-wal,参考只靠 wal_mtime 触发重解密主库(仍缺 WAL 内消息);本项目直接解密 WAL 帧(同算法、帧头明文、salt 匹配过滤、后帧覆盖)按页号合并进主库副本,更新 SQLite 头页数字段,`PRAGMA quick_check` 验证(`test_wal_merge.py`)
|
||||
- **会话指纹增强**:`session_fp_from_name` 用与 `wechat_bot._fp_from_name` 完全一致的算法(md5+blake2b,40 字节)→ DB 直读 fp_hex 与引擎 A identity_by_name 模式同指纹空间
|
||||
- `normalize_name` 仅标准库实现(NFKC+去空白),避免 import session_name(其顶层依赖 numpy)
|
||||
- `WXWorkDB` 类:构造时解密缓存+元数据,`get_new_messages(since_ts)` 秒级 send_time 增量查询,`_refresh_if_needed` 按 mtime 自动重解密
|
||||
- `engine_b.py`(改造):`DataEngine` 加 `db_source` 参数(WXWorkDB 实例);`_run` 有 DB 则 `_poll_db_once`;DB 直读失败自动回退 `poll_once`(conversations.json),下轮自动重试恢复;DB 路径**按 fp_hex 聚合**只投递每会话最后一条(防历史逐条误投);初始游标 = now-120s(与启动新鲜窗口一致);`_process_entry` 提取为两条路径共用
|
||||
|
||||
### 验证结果
|
||||
- `wxwork_db.py` 冒烟:2 账号可读(`1688856770803435`+`1688855555743233`)、1041 条单聊消息、health_check 通过
|
||||
- 增量查询命中真实消息:今天 11:06:05「一个小迷糊: 在不」
|
||||
- 指纹一致性:`' 高瑞 @微信 '` 的 fp 前 16 字节与 conversations.json 档案 key 完全一致 → 引擎 A 正运行在 identity_by_name 模式
|
||||
- WAL 合并:quick_check ok(当前 WAL 无更新帧,机制已验证)
|
||||
- 引擎 B 真实集成:FakeDB 聚合单测通过(多历史只投最新一条/self 过滤/去重/降级回退)
|
||||
- `test_dual_engine.py`:31 项全部通过(系统 Python 3.11 跑)
|
||||
- 密钥匹配验证:仅 2 账号可解密,其余 9 账号无密钥(与参考项目空目录印证)
|
||||
|
||||
### 关键结论
|
||||
- 密钥确认在主库页 1 可验证(`verify_key` 0.03s),企微运行时 WAL 帧同算法加密可合并 → 实时性方案成立
|
||||
- DB 直读 fp_hex 与引擎 A 档案同指纹空间 → 投递后可被引擎 A 无缝定位窗口回复
|
||||
- 回归命令:managed venv(`C:\Users\pc\.workbuddy\binaries\python\envs\default\Scripts\python.exe`)跑 wxwork_db/engine_b;系统 Python 3.11 跑 test_dual_engine(wechat_bot 依赖 win32gui)
|
||||
|
||||
---
|
||||
|
||||
## 引擎 B 数据源改为「两路并行双跑」(用户拍板)
|
||||
|
||||
### 背景
|
||||
用户质疑"是否有两套方案兜底,而不是合到一起了"。原实现 DB 与 JSON 是**主备二选一**(`_run` 有 DB 只跑 `_poll_db_once`,DB 异常才 `return self.poll_once()` 降级)——DB 正常时 JSON 路径完全不执行,存在单点盲区。用户选择改为**并行双跑**。
|
||||
|
||||
### 修改(engine_b.py)
|
||||
- `_run`:每轮无条件调用 `_poll_db_once()` + `poll_once()`(两路独立检测,互为兜底)
|
||||
- 游标分离:新增 `_seen_db`(DB 路径会话游标),`_seen` 只归 JSON 路径;`_process_entry` 加 `seen` 参数(DB 传 `_seen_db`,JSON 用默认),两路独立建档/判定、互不污染
|
||||
- `_poll_db_once`:去掉降级 return,DB 失败仅 `db_active=False` 并打印,JSON 路径不受影响
|
||||
- 去重统一走 `_maybe_enqueue` 的 dedup_key(fp_hex:时间戳,300s 防抖 + 队列级去重)——同一条消息两路都发现也只投一次
|
||||
|
||||
### 验证
|
||||
- 引擎 B 冒烟通过;并行单测全部断言通过:同消息只投一次 / 两路各自增量 / 游标独立 / DB 崩溃不影响 JSON
|
||||
- 真实 DB 并行集成:db_active=True、0 重复投递
|
||||
- `test_dual_engine.py`:31 项全过
|
||||
|
||||
### 架构语义(最终形态)
|
||||
引擎 A(截图 RPA)与引擎 B(数据直读)两套独立检测通道;引擎 B 内部 DB 直读 + conversations.json **并行双跑**互为兜底;发送统一收敛引擎 A send_reply + send_lock 互斥。
|
||||
|
||||
---
|
||||
|
||||
## GUI 引擎切换卡片 + 全量回归(交付)
|
||||
|
||||
### GUI 改动(wechat_gui_qt.py)
|
||||
- `DashboardPage` 加「检测引擎」卡片:引擎 A 开关(`engine_a_check`)、引擎 B 开关(`engine_b_check`)、引擎 B 数据源三选一(`QRadioButton` 并行双跑/仅DB/仅JSON);引擎 B 关闭时数据源单选组置灰(`_sync_engine_b_source_enabled`)
|
||||
- 信号:复用已有 `settingsChanged` 信号 → `MainWindow._on_dashboard_engine_settings`(合并进 runtime_settings + 持久化)
|
||||
- 键名统一为 **`engine_a_enabled`**(注意不是 `enable_engine_a`!BotThread 参数、_runtime_defaults、_load_runtime_settings 校验、start_monitoring 传参、DashboardPage 读写全部用 `engine_a_enabled`);`enable_engine_b`、`engine_b_data_source` 不变
|
||||
- `_load_runtime_settings` 校验:`engine_a_enabled`/`enable_engine_b` 转 bool、`engine_b_data_source` 白名单(非法回落 parallel)
|
||||
|
||||
### 运行时改动(gui_runtime.py)
|
||||
- `BotThread.__init__` 加 `engine_a_enabled=True`、`engine_b_data_source="parallel"`(含合法性回落)
|
||||
- 引擎 B 启动段:按数据源模式决定是否接 DB(parallel/db 才初始化 WXWorkDB,失败仅记日志 db_source=None)
|
||||
- 主循环:`bot._poll_once(scan_unread=self.engine_a_enabled)`(引擎 A 关闭→跳过红点扫描,只留队列恢复,B 投递仍能发送)
|
||||
|
||||
### 修复的真实 bug
|
||||
- `engine_b.py::_poll_db_once`:DB 故障恢复后若首轮查询为空,`db_active` 永不回 True(`if not msgs: return` 跳过置位)→ 查询成功即置 `db_active=True`(空返回前)
|
||||
|
||||
### 验证
|
||||
- 语法:5 核心模块 py_compile 通过
|
||||
- GUI 卡片冒烟(`tmp/qt_engine_card_smoke.py`,offscreen):18 项全过(初始态/信号键集合/radio 置灰联动/设置加载校验/合并保存持久化)
|
||||
- 引擎 B 三模式单测(`tmp/test_engine_b_modes.py`):14 项全过(json 只跑 JSON、db 只跑 DB、db 无源退化 JSON、parallel 双跑、DB 异常隔离+恢复、非法模式回落)
|
||||
- 回归:`test_dual_engine.py` 31 项、`test_runtime_settings.py` 18 项、`test_qt_console_bridge.py` 14 项、`test_qt_capsule.py` 19 项全过
|
||||
- **测试适配**:`test_runtime_settings.py` 的 mock `poll_once()` 改为 `poll_once(scan_unread=True)`(gui_runtime 新调用契约)
|
||||
|
||||
### 运行语义提示
|
||||
- GUI 改引擎开关:运行中修改→保存文件,**本轮监听结束后重启才完全生效**(BotThread 启动时读取)
|
||||
- 引擎 A 关闭 ≠ 不回复:B 投递的消息由 A 的队列恢复通道照常定位发送,只是不再截图扫红点
|
||||
|
||||
---
|
||||
|
||||
## 修复:视觉定位"卡死"(bot 线程假死)
|
||||
|
||||
### 现象
|
||||
用户截图显示任务卡在"正在视觉定位"(opening),日志在 `_find_next_unread_session` 检测到总未读后完全中断,bot 线程约 4-5 分钟无响应。
|
||||
|
||||
### 根因
|
||||
`_scroll_session_list_page` 滚动到底后,由于时间戳/动画等微小像素差异,`_session_page_signature` 认为页面不同,返回 `after` 而非 `None`。导致:
|
||||
- `_find_pending_session` / `_find_next_unread_session` 持续翻页扫描 240 页(`SESSION_SCAN_MAX_PAGES`)或 45 秒超时
|
||||
- 4 个 pending 恢复任务 × 45 秒 = 3 分钟+/轮
|
||||
- 每轮结束后冷却期短(`SESSION_SCAN_COOLDOWN_SECONDS=15s`,失败按 `2^failures` 退避),任务反复被扫描
|
||||
- 日志长时间无输出,GUI 状态停滞,用户感知为"卡死"
|
||||
|
||||
### 修复(wechat_bot.py)
|
||||
1. `_session_pages_overlap_at_shift`:放宽 `shift` 边界检查从 `<=0` 到 `<0`,允许 `shift=0` 检测(同页内容完全匹配)
|
||||
2. 新增 `_any_scroll_overlap(before, after)`:在 `0 ~ 2*item_h` 范围内遍历合理 shift,检测是否存在 >=90% 的内容重叠
|
||||
3. `_scroll_session_list_page`:签名比较后增加 `_any_scroll_overlap` 兜底——如果签名不同但无有效滚动重叠,返回 `None`(认为到底),阻止无效翻页
|
||||
|
||||
### 验证
|
||||
- 语法 py_compile 通过
|
||||
- `test_dual_engine.py` 31 项、`test_engine_b_modes.py` 14 项全过
|
||||
- `tmp/test_scroll_deadlock_fix.py`:
|
||||
- 同页 shift=0 返回 True ✓
|
||||
- 时间戳微差(签名不同但无有效滚动)→ `_any_scroll_overlap` 返回 True → `_scroll_session_list_page` 会返回 None ✓
|
||||
- 真实滚动 72px → `_any_scroll_overlap` 返回 True,滚动仍被识别 ✓
|
||||
|
||||
### 影响
|
||||
- 修复后:滚动到底立即返回 None,不再做 45 秒无效扫描
|
||||
- 极少量场景(动画/加载导致大面积像素变化)可能提前结束扫描,遗漏极底部未读——但相比卡死 3 分钟+,可接受;下一轮轮询会重新扫描
|
||||
|
||||
---
|
||||
|
||||
## 修复:队列任务永远"处理中"(快速放弃机制)
|
||||
|
||||
### 现象
|
||||
用户截图:3 个任务(一个小迷糊@微信/在线问诊/高瑞)全部 stage=opening、resume_failures=7/3/2,监听停止后 GUI 仍显示"处理中"。用户诉求:AI 回复完成就应移除任务,除非有新消息。
|
||||
|
||||
### 根因
|
||||
- 发送成功(sent)任务本就会 `_clear_reply_pending` 移除——但**卡在 opening 的任务没有快速放弃机制**
|
||||
- `_UNREACHABLE_RESUME_FAILURES=8`:纯定位任务要失败 8 次才丢弃,加上 `2^failures` 指数退避(最长 600s),最坏情况任务挂几小时
|
||||
- 已生成 AI 回复(staged_reply_text 非空)却发不出去的任务,回复有时效性,继续挂着毫无意义
|
||||
|
||||
### 修复(wechat_bot.py)
|
||||
1. 新增 `_STAGED_REPLY_EXPIRE_FAILURES = 3`:有 staged_reply_text 的任务失败 3 次即放弃(回复未发出、不落档,客户新消息会重新建单)
|
||||
2. `_expire_unreachable_pending` 按是否有 staged_reply_text 选阈值:有回复→3,纯定位→8(保留原语义,防企微暂未开误删)
|
||||
3. 放弃时打印区分原因(有过时回复 / 找不到会话)
|
||||
|
||||
### 验证
|
||||
- 语法 py_compile 通过
|
||||
- `tmp/test_pending_expire.py` 7 项全过(staged 3 次放弃/2 次保留、纯定位 8/7 边界、放弃不落档、非法值与空白回复边界)
|
||||
- 回归:`test_dual_engine.py` 31 项、`test_engine_b_modes.py` 14 项、`test_runtime_settings.py` 18 项全过
|
||||
|
||||
### 用户队列现状(修复后重启监听即生效)
|
||||
- 一个小迷糊@微信(failures=7,有回复)→ 下次失败即达 3 → 放弃
|
||||
- 在线问诊(failures=3)→ 立即达阈值 → 放弃
|
||||
- 高瑞(failures=2)→ 再失败 1 次 → 放弃
|
||||
|
||||
---
|
||||
|
||||
## 任务池闭环验证(发送成功移除 + 新消息重新投递)
|
||||
|
||||
用户确认诉求:AI 回复成功→任务移除;对方新消息→算新任务重新入队,任务池顺畅。
|
||||
|
||||
### 闭环语义(全部已确认成立)
|
||||
- **发送成功(sent)→ `_clear_reply_pending` 移除任务**(`_resume_orphaned_pending_reply` 发送对账路径)
|
||||
- **新消息 = 新 dedup_key**:`dedup_key = fp_hex:消息ts`,DB 消息在 `_poll_db_once` 归一化为 `{"content", "ts"}`(ts=send_time),新消息 ts 不同 → 不撞防抖
|
||||
- **`_recently` 300s 防抖只挡同一条消息**(同 dedup_key),新消息不受影响
|
||||
- **`has_active_pending` 对终态(sent/cancelled/expired/cleared)返回 False** → 任务移除后引擎 B 可重新投递
|
||||
- **队列已有活跃任务时引擎 B 只补 detected_by 标记不重复投递**
|
||||
|
||||
### 验证
|
||||
- `tmp/test_queue_loop.py` 10 项全过:投递→防抖→发送移除→新消息重新投递→二次防抖→活跃不重复
|
||||
- 注意:测试数据字段用 `ts`(不是 `send_time`),`_msg_ts` 读 `msg["ts"]`
|
||||
- 回归:`test_dual_engine.py` 31、`test_engine_b_modes.py` 14、`test_pending_expire.py` 7 全过
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 项目长期记忆 — 企业微信 RPA 自动化
|
||||
|
||||
## 项目背景
|
||||
- 企业微信(WeCom)消息自动回复,核心:红点检测 → 会话定位 → 提取文本 → AI 回复 → GUI 发送。
|
||||
- 企微用自定义 GPU 渲染(UIA 节点=0),**无法**用 UIAutomation/win32gui 内部控件查询,只能截图 + 坐标。
|
||||
- 本地库(message.db 等)为 wxSQLite3 加密(文件头 `4fa4c7db855d54ad`),需内存提密钥 + 解密,普通 sqlite3 打不开。
|
||||
- 主 GUI:`wechat_gui_qt.py`(PySide6),后台运行时:`gui_runtime.py`(BotThread),核心:`wechat_bot.py`(13664 行,WeChatBot 类)。
|
||||
- AI 统一入口:`ai_chat.get_ai_reply(chat_text, image_bytes, history, *, force_vision, media_types)`,Dify 提供商(gpt-5.6-sol),`ai_settings.json` 配置。
|
||||
|
||||
## 双引擎架构(2026-08-21 落地)
|
||||
- **引擎 A**(截图 RPA):红点识别 → 打开会话 → 视觉提取 → `_generate_ai_reply` → `send_reply`。发送收敛点。
|
||||
- **引擎 B**(数据直读,`engine_b.py`):**两路并行双跑、互为兜底**——① DB 直读(升级数据源)② `conversations.json`(引擎 A 视觉回写档案)每轮都独立检测;任一数据源有盲区另一路补漏,同一条消息靠 dedup_key 去重(fp_hex:时间戳,300s 防抖+队列级去重)只投一次 → `bot.enqueue_detected()` 投共享队列 → 由引擎 A 的 `_resume_orphaned_pending_reply` 统一处理。B 不碰窗口。两路游标独立:`_seen`(JSON)/`_seen_db`(DB),DB 失败仅停 DB 路(db_active=False)。
|
||||
- **DB 直读**(`wxwork_db.py`,Phase 2 主线,密钥已验证):密钥在 `wxwork_keys.json`(2 账号:`1688856770803435`→`db7288a302bfbe66563d81f025bde977`、`1688855555743233`→`d18e7f9a222665389989066f929ece19`,其余 9 账号无密钥/未登录)。解密缓存 `wxwork_decrypted/`,WAL 感知(帧同算法加密,可合并拿 checkpoint 前最新消息)。`get_new_messages(since_ts)` 秒级 send_time 增量查询,`is_personal_chat` 只留 M:/S: 单聊。fp_hex = `session_fp_from_name`(md5+blake2b 40 字节,与引擎 A identity_by_name 模式一致)。
|
||||
- **共享队列** `pending_replies.json`:字段白名单读写(`_load_pending_replies`/`_persist_pending_replies_unlocked`)。新字段必须两处透传!现有 44 字段含:dedup_key(去重)、detected_by(engine_a/engine_b)、detection_ts、sending_owner、send_lock_ts。
|
||||
- **发送互斥** `send_lock.py`:Windows 命名互斥量 `Global\WeChatBotSendLock` + 线程持有者跟踪。`send_reply()` 入口抢占,拿不到即让出。
|
||||
- 引擎开关(`app_settings.json`,GUI「检测引擎」卡片可改):`engine_a_enabled`(默认 true,注意键名不是 enable_engine_a!)、`enable_engine_b`(默认 true)、`engine_b_poll_interval`(2.0)、`engine_b_data_source`(parallel/db/json,非法回落 parallel)。运行时在 BotThread 启动时读取,GUI 改后本轮结束重启生效。
|
||||
- 引擎 A 关闭(`engine_a_enabled=false`)≠ 不回复:`_poll_once(scan_unread=False)` 跳过红点截图扫描,但队列恢复照常,B 投递的消息仍被定位发送。
|
||||
|
||||
## 关键约定
|
||||
- 队列任务 stage 状态机:queued→opening→reading→collecting→generating→ready_to_send→sending→sent,另有 manual_review/retry_wait/call_paused/uncertain。
|
||||
- 发送频率限制:`MAX_SENDS_PER_MINUTE=4`、`MAX_SENDS_PER_HOUR=60`(`_send_gate`)。
|
||||
- 测试注意:wechat_bot.py 依赖 win32gui/mss/numpy/pyautogui,**只能用系统 Python 3.11 跑**(`C:\Users\pc\AppData\Local\Programs\Python\Python311\python.exe`),managed python 3.13 无这些包。
|
||||
- 构造最小 bot 对象用 `WeChatBot.__new__(WeChatBot)` + 手工补 `_pending_lock/_pending_reply_sessions/_pending_reply_path/_cancelled_reply_sessions`。
|
||||
|
||||
## 待办/未来方向
|
||||
- Phase 2 DB 直读主线已打通(密钥就位 + WAL 合并 + 指纹对齐 + 引擎 B 集成),回归 31 项通过。剩余:长稳联跑验证(企微运行中轮询 WAL 增量 → 引擎 A 回复闭环)。
|
||||
- 机器人对接口子:ai_chat(已用)/ MCP 增强(AI_MCP_ENABLED + mcp_bridge)/ 外部 Agent(mcp_server.py)。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 智能体与「全部账号」已同步到协议版客户端
|
||||
|
||||
2026-09-16 把 `C:\kefu` 那边开发的三件事同步到本目录(协议版客户端,v1.4.16)。
|
||||
|
||||
## 同步了什么
|
||||
|
||||
**一、智能体(角色 + 规则)**——客户端这边是真正用得上的部分:
|
||||
|
||||
- 新增 `agent_rules.py`:人设与规则的纯逻辑层(guide 补充指令 / forbid 禁止措辞 / reply 固定口径),单角色切换与多角色协作都在这里。
|
||||
- `ai_config.py`:新增 `AI_AGENTS` / `AI_AGENT_PLAN` 两个下发字段,并在内置人设里加了一段「客户发来短消息 / 数字时严禁说成乱码」。
|
||||
- `ai_chat.py`:系统提示词追加角色段落、出口校验禁止措辞、固定口径直接回、纯数字单独给一条会话指令。
|
||||
|
||||
协议版的发送链路走 `protocol_engine._generate` → `ai_chat.get_ai_reply`,所以上面这些自动生效,协议引擎本身不用改。配了本地裁判时走 `model_router.answer`,它内部同样调 `ai_chat.get_ai_reply`,也一样覆盖。
|
||||
|
||||
**二、线上那条错误回复的修复**:客户发来血糖值「13」,模型回"这串像系统编码"。提示词、会话指令、出口兜底三处都加了拦截,最后一道关不掉也不依赖配置——一个智能体都不配时照样生效。
|
||||
|
||||
**三、后台的「全部账号」范围**:`admin_backend.tenant_filter`、`archive_store.TenantScope`、`admin_api.ALL_ACCOUNTS` 与写路径的 `single_tenant` 守卫。本目录没有 `admin-web` 前端,所以管理端页面那部分(智能体页、账号下拉框默认全部)在这里不适用,只同步了后端能力,避免以后从这个目录起后台时功能对不上。
|
||||
|
||||
## 怎么同步的
|
||||
|
||||
`ai_chat.py` / `ai_config.py` / `rbac.py` 在两个目录里原本逐字节相同(只差我这次的改动),直接覆盖。`admin_backend.py` / `admin_api.py` / `archive_store.py` / `archive_api.py` 两边已经因为别的原因分叉(本目录的接口是 async 写法,`archive_store` 还是归档性能优化之前的版本),所以是**按差异移植**,没有整文件覆盖——那样会把 `C:\kefu` 那边不相干的改动一起带过来。
|
||||
|
||||
`export_jobs` 在本目录还是旧的 N+1 写法,补丁按这边的形状单独适配过。
|
||||
|
||||
## 没有同步的
|
||||
|
||||
- **归档性能优化**(游标 SQL、`message_count` 的租户谓词、`desktop_accounts` 的 `include_counts`):那是 9 月 14 日在 `C:\kefu` 做的另一件事,和这次无关,本目录仍是旧版。要不要跟进是另一个决定。
|
||||
- **知识库**(`knowledge_*.py`、`knowledge:*` 权限码):`C:\kefu` 那边正在并行开发,本目录没有这套文件,这次没碰。
|
||||
|
||||
## 验证
|
||||
|
||||
本目录跑过:归档 25、管理端与数据层 204、客户端 AI 525、协议链路 100、打包 30、网关与路由等 181,合计 1065 个用例全部通过。
|
||||
|
||||
|
||||
## 后续完整整合
|
||||
|
||||
同日后续整合已补齐本文当时尚未同步的知识库、归档性能和管理前端,另补充协议回复知识引用与风险审核。本文保留此前同步记录;当前功能和启动方式以 [INTEGRATION_20260916.md](INTEGRATION_20260916.md) 为准。
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# 配置管理后台
|
||||
|
||||
项目内置配置后台,用于统一管理登录账号、角色、AI 模型、MCP 和聊天知识库。
|
||||
|
||||
> 当前协议版已包含完整前端、知识中心和 Worker。使用说明见 [INTEGRATION_20260916.md](INTEGRATION_20260916.md),服务端部署见 [deploy/im-admin/README.md](deploy/im-admin/README.md)。完整后台应使用 `python run_backend.py --db backend.db` 启动 API、网关和知识 Worker;单独启动 API 不执行后台知识任务。
|
||||
|
||||
## 1. 启动后台
|
||||
|
||||
在项目目录运行:
|
||||
|
||||
```powershell
|
||||
python admin_api.py
|
||||
```
|
||||
|
||||
后台监听 `8766`(管理界面 + JSON 接口 + 桌面端同步都在这一个服务上)。端口被占用时会直接报错退出,不再静默顺延——顺延过的端口没人知道,排查时只会看到"连不上"。
|
||||
桌面端会读取 `backend_runtime.json` 自动发现实际地址,无需手工修改。
|
||||
|
||||
> 早期版本还有一个 `8765` 的网页后台(服务端渲染 HTML)。它已整体退役:界面由 Vue 管理端承担,桌面端同步、调用留痕上报也都并进了 `admin_api.py`。`admin_backend.py` 仍在,但只作为数据层被引用,不再自己起服务。
|
||||
|
||||
浏览器打开控制台打印的地址。首次创建数据库时使用:
|
||||
|
||||
- 用户名:`admin`
|
||||
- 初始密码:`Admin@123456`
|
||||
|
||||
首次登录必须修改密码。也可以在第一次启动前通过环境变量 `WECOM_ADMIN_INITIAL_PASSWORD` 设置不同的初始密码。
|
||||
|
||||
数据库默认保存在 `backend.db`,已加入 `.gitignore`。首次建库会读取当前 `ai_settings.json` 作为第一版模型配置。
|
||||
|
||||
## 2. 用户与角色
|
||||
|
||||
- 管理员(admin):管理用户、角色并发布模型配置。
|
||||
- 配置员(operator):查看和发布模型配置,不能管理用户。
|
||||
- 只读用户(viewer):查看后台并登录桌面端同步配置,不能修改配置。
|
||||
|
||||
管理员创建的新用户第一次登录网页时也必须修改初始密码,之后才能从桌面端登录。
|
||||
|
||||
## 3. 桌面端自动配置
|
||||
|
||||
启动 `wechat_gui.py`,进入“AI 人格与能力”页:
|
||||
|
||||
1. 点击“登录后台”。
|
||||
2. 填写后台地址、用户名和密码。
|
||||
3. 登录成功后,后台配置立即写入本机 `ai_settings.json` 并生效。
|
||||
4. 桌面端以后会在启动时及每 5 分钟自动同步,也可以点击“立即同步”。
|
||||
|
||||
本机只保存 30 天有效的访问令牌,不保存后台密码。登录后台并启用自动同步后,本地 AI 配置字段会变成只读,以后台配置为准;退出后台后可恢复本地编辑。
|
||||
|
||||
后台发生端口冲突并自动更换端口时,同一项目目录中的桌面端会自动发现新地址;已有登录令牌可以继续使用。
|
||||
|
||||
同一台电脑上启动后台与桌面端时,后台会在 `backend_runtime.json` 发布一个仅限回环地址使用的临时只读同步凭证。桌面软件启动时会先检测该服务并拉取最新配置,所以即使没有保存后台账号登录,也会立即刷新“能力开关”“模型与身份”和“MCP 服务器”中的内容。该凭证不能管理用户或修改后台配置,后台停止后即失效。
|
||||
|
||||
## 3.1 智能体(角色与规则)
|
||||
|
||||
管理端「AI 模型 → 智能体」决定客服**用什么身份、按什么规矩说话**;模型清单和角色编排决定**用哪个模型**。两件事分开配,因为变更频率差一个数量级——模型半年动一次,话术一周动三次。
|
||||
|
||||
一个智能体 = 人设 + 一组规则。规则只有三种:
|
||||
|
||||
| 类型 | 触发 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| 补充指令(guide) | 关键词留空 = 每轮都注入;填了关键词 = 客户这句话命中才注入 | 追加一句给模型的要求 |
|
||||
| 禁止措辞(forbid) | **回复里**出现关键词 | 换成这条规则的兜底话术;留空则用客户端内置的那句 |
|
||||
| 固定口径(reply) | **客户这句话**命中关键词 | 直接发配好的话术,不问模型 |
|
||||
|
||||
「启用与协作」里有两种模式:
|
||||
|
||||
- **单角色**:只上一个,所谓"切换智能体"就是换这里选的那个。
|
||||
- **协作**:多个同时在场,客户这句话命中谁的「负责话题」就由谁主答,其余角色的禁止措辞**仍然全体生效**。不论哪种模式,客户看到的始终是同一个人——不会自报角色名,也不会出现"转给同事"。
|
||||
|
||||
方案按版本追加,不原地改,回滚就是再存一版指回去。保存后随桌面端配置下发,客户端不用发版。
|
||||
|
||||
出厂自带「健康顾问」和「客户经理」两个角色,默认按协作上岗。两个角色都带同一条禁止措辞规则:把客户看得见的文字说成"系统编码/乱码/无法确认具体意思"一律拦下——这是线上真实发生过的事故(客户发来血糖值「13」,模型回"这串像系统编码")。即使一个智能体都不配,客户端里那条内置兜底也照样生效。
|
||||
|
||||
相关权限码:`agent:read` / `agent:write`。升级到这个版本的老后台,管理员自动获得;配置员和只读用户需要在「角色权限」里勾一下。
|
||||
|
||||
## 4. 连接已部署的后台
|
||||
|
||||
后台所在电脑启动服务:
|
||||
|
||||
```powershell
|
||||
python admin_api.py --host 0.0.0.0 --port 8766
|
||||
```
|
||||
|
||||
桌面端后台地址填写服务器的局域网地址,例如 `http://192.168.1.20:8766`。需要在 Windows 防火墙中仅对可信局域网放行该端口。
|
||||
|
||||
Docker、云服务器、HTTPS、备份和升级步骤统一放在 [deploy/im-admin/README.md](deploy/im-admin/README.md),不再与桌面端使用说明混在一起。
|
||||
|
||||
## 5. 管理员密码恢复
|
||||
|
||||
停止后台后运行:
|
||||
|
||||
```powershell
|
||||
python admin_api.py --reset-admin-password
|
||||
```
|
||||
|
||||
按提示输入新密码。重置后已有 admin 登录令牌会失效,下次登录仍需再修改一次密码。
|
||||
|
||||
## API
|
||||
|
||||
- `POST /api/v1/auth/login`:桌面端登录并取得令牌。
|
||||
- `POST /api/v1/auth/logout`:注销令牌。
|
||||
- `GET /api/v1/me`:读取当前用户及角色。
|
||||
- `GET /api/v1/config`:读取当前版本的模型配置。
|
||||
- `POST /api/v1/model/test`:使用当前提交的模型地址、名称和可选密钥测试连通性(仅管理员和配置员;不会保存配置)。
|
||||
- `GET /api/v1/desktop/config`:桌面软件启动时只读同步云端配置。
|
||||
- `GET /health`:健康检查。
|
||||
|
||||
模型测试支持三种 `AI_PROVIDER_TYPE`:
|
||||
|
||||
- `openai`:OpenAI 兼容接口,向 `chat/completions` 发送最小对话请求。
|
||||
- `dify`:Dify 应用接口,向 `chat-messages` 发送 blocking 请求。
|
||||
- `comfyui`:ComfyUI 文生图服务,通过 `GET /system_stats` 检查服务状态。
|
||||
|
||||
管理页面可直接选择服务类型并点击“测试模型连通性”。测试使用页面当前值,不保存配置;API Key 留空时沿用后台已保存值,响应和审计记录均不会包含密钥。
|
||||
|
||||
## 云端开发模式
|
||||
|
||||
管理员或配置员可在“能力开关”中开启“开发模式”并发布。桌面端下次启动或定时同步后,会在“运行日志”显示:
|
||||
|
||||
- 云端配置请求的具体地址、配置版本和更新时间;
|
||||
- 云端返回的具体配置;
|
||||
- 每次模型调用采用的服务类型、模型名称、基础地址和最终请求地址。
|
||||
|
||||
所有诊断均会隐藏 API Key、Token、密码、认证头、Cookie 等敏感值,也不会输出聊天内容。关闭开关并发布后,桌面端下次同步起停止输出这些诊断信息。
|
||||
@@ -0,0 +1,104 @@
|
||||
# 后台独立部署
|
||||
|
||||
配置后台是一个独立的 HTTP 服务。桌面端不会自动把后台部署到服务器;必须先在一台长期在线的电脑或服务器上启动后台,再让各桌面端连接它。
|
||||
|
||||
使用宝塔面板时,请直接查看 [BAOTA_DEPLOY.md](BAOTA_DEPLOY.md),里面按宝塔的“文件、Docker、网站、SSL、反向代理”页面逐步说明。
|
||||
|
||||
## 需要部署哪些文件
|
||||
|
||||
最小部署只需要:
|
||||
|
||||
- `admin_backend.py`:后台程序,只有 Python 标准库依赖。
|
||||
- `ai_settings.json`:可选,仅用于第一次创建数据库时导入初始模型配置。
|
||||
|
||||
以下文件不要从开发电脑复制到新服务器:
|
||||
|
||||
- `backend_runtime.json`:本机运行时自动生成,包含临时同步凭证。
|
||||
- `backend_connection.json`:桌面端的登录状态,不属于后台。
|
||||
- `backend.db`:新部署无需复制;迁移旧后台时才复制,并应妥善保管。
|
||||
|
||||
后台首次启动后会创建 `backend.db`。它保存账号、配置和审计记录,是需要定期备份的核心数据文件。
|
||||
|
||||
## 方案一:Docker 部署(推荐)
|
||||
|
||||
项目已经把 Docker 部署文件单独放在 `backend_deploy` 目录中。在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
Copy-Item backend_deploy\.env.example backend_deploy\.env
|
||||
notepad backend_deploy\.env
|
||||
docker compose --env-file backend_deploy/.env -f backend_deploy/compose.yaml up -d --build
|
||||
```
|
||||
|
||||
编辑 `.env` 时至少设置一个不少于 10 位、同时包含字母和数字的初始管理员密码:
|
||||
|
||||
```dotenv
|
||||
WECOM_ADMIN_INITIAL_PASSWORD=请替换为强密码
|
||||
BACKEND_BIND_IP=127.0.0.1
|
||||
BACKEND_PORT=8765
|
||||
```
|
||||
|
||||
`BACKEND_BIND_IP` 的选择:
|
||||
|
||||
- 只允许服务器本机访问:保持 `127.0.0.1`。
|
||||
- 需要局域网电脑直连:改为 `0.0.0.0`,并只对可信局域网放行端口。
|
||||
- 需要公网访问:保持 `127.0.0.1`,通过 Nginx 或 Caddy 提供 HTTPS,禁止直接暴露 HTTP 端口。
|
||||
|
||||
检查运行状态:
|
||||
|
||||
```powershell
|
||||
docker compose --env-file backend_deploy/.env -f backend_deploy/compose.yaml ps
|
||||
Invoke-RestMethod http://127.0.0.1:8765/health
|
||||
```
|
||||
|
||||
查看日志:
|
||||
|
||||
```powershell
|
||||
docker compose --env-file backend_deploy/.env -f backend_deploy/compose.yaml logs -f backend
|
||||
```
|
||||
|
||||
停止服务不会删除数据库:
|
||||
|
||||
```powershell
|
||||
docker compose --env-file backend_deploy/.env -f backend_deploy/compose.yaml down
|
||||
```
|
||||
|
||||
不要执行 `down -v`,它会删除保存后台数据库的 Docker 数据卷。
|
||||
|
||||
## 方案二:直接运行 Python
|
||||
|
||||
服务器安装 Python 3.10 或更高版本后,把 `admin_backend.py` 放到一个单独目录中,然后运行:
|
||||
|
||||
```powershell
|
||||
python admin_backend.py --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
该方式不需要安装 `requirements.txt` 中的桌面端依赖。正式使用时应把命令配置为 Windows 服务、计划任务或 Linux systemd 服务,确保服务器重启后自动启动。
|
||||
|
||||
首次启动默认账号为 `admin`。如果没有通过环境变量指定密码,默认初始密码为 `Admin@123456`;首次登录会强制修改。正式部署不要继续使用默认密码。
|
||||
|
||||
## 桌面端如何连接
|
||||
|
||||
1. 浏览器打开后台地址并完成管理员首次登录和密码修改。
|
||||
2. 在后台发布模型与 MCP 配置。
|
||||
3. 打开桌面端“AI 人格与能力”页面。
|
||||
4. 在“后台同步”区域点击“登录后台”。
|
||||
5. 后台地址填写服务器地址,例如 `http://192.168.1.20:8765`;使用反向代理时填写 `https://你的域名`。
|
||||
|
||||
登录后桌面端会立即同步,此后会在启动时和运行期间自动同步。
|
||||
|
||||
## 数据备份与升级
|
||||
|
||||
Docker 部署的数据保存在 `wechat-backend-data` 数据卷中。升级前先备份数据库,再重新构建服务:
|
||||
|
||||
```powershell
|
||||
docker compose --env-file backend_deploy/.env -f backend_deploy/compose.yaml up -d --build
|
||||
```
|
||||
|
||||
直接运行 Python 时,停止后台后复制 `backend.db` 即可完成一致性备份。恢复时把数据库放回原目录,再启动后台。
|
||||
|
||||
## 常见连接失败
|
||||
|
||||
- `WinError 10061`:目标地址没有服务监听。确认后台进程或容器已经启动,并核对 IP 与端口。
|
||||
- 服务器本机能打开、其他电脑不能:确认监听地址为 `0.0.0.0`,并检查系统防火墙和云服务器安全组。
|
||||
- 浏览器能打开、桌面端不能:登录框中的地址必须包含 `http://` 或 `https://`,不要填写后台管理页的子路径。
|
||||
- 公网域名连接失败:检查 HTTPS 证书与反向代理,不要在桌面端使用自签名证书。
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
# 宝塔面板部署后台
|
||||
|
||||
推荐架构:`桌面端 → HTTPS 域名 → 宝塔 Nginx → 127.0.0.1:8765 → 后台容器`。
|
||||
|
||||
后台端口只监听服务器本机,不在云服务器安全组或宝塔防火墙中开放 `8765`。公网只开放 `80` 和 `443`。
|
||||
|
||||
## 一、准备域名
|
||||
|
||||
准备一个单独的二级域名,例如:
|
||||
|
||||
```text
|
||||
ai-admin.example.com
|
||||
```
|
||||
|
||||
在域名服务商处添加 A 记录,指向宝塔服务器的公网 IP。等待解析生效后再申请 SSL 证书。
|
||||
|
||||
## 二、上传后台文件
|
||||
|
||||
在宝塔“文件”中创建目录:
|
||||
|
||||
```text
|
||||
/www/wwwroot/wechat-backend
|
||||
```
|
||||
|
||||
推荐直接上传本项目生成的 `backend_deploy/wechat-backend-baota.zip`,并解压到上述目录。需要重新生成部署包时,在项目目录运行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File backend_deploy/build_baota_bundle.ps1
|
||||
```
|
||||
|
||||
解压后应是以下结构,不需要上传桌面端的其他 Python 文件:
|
||||
|
||||
```text
|
||||
/www/wwwroot/wechat-backend/
|
||||
├── admin_backend.py
|
||||
├── Dockerfile
|
||||
├── compose.yaml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
在宝塔“文件”中把 `.env.example` 复制为 `.env`,再修改为:
|
||||
|
||||
```dotenv
|
||||
WECOM_ADMIN_INITIAL_PASSWORD=替换成至少10位且包含字母和数字的强密码
|
||||
BACKEND_PORT=8765
|
||||
```
|
||||
|
||||
不要上传本机的 `backend_runtime.json`、`backend_connection.json` 或旧的 `.env`。
|
||||
|
||||
## 三、使用宝塔“Python 项目”启动
|
||||
|
||||
如果已经进入“添加 Python 项目”窗口,按下面填写:
|
||||
|
||||
| 字段 | 填写内容 |
|
||||
| --- | --- |
|
||||
| 项目名称 | `xchat` |
|
||||
| Python 环境 | Python `3.10`、`3.11` 或 `3.12`,推荐 `3.12`;不能使用截图中的 `3.6.8` |
|
||||
| 启动方式 | `命令行启动` |
|
||||
| 项目路径 | `/www/wwwroot/xchat`,以实际上传目录为准 |
|
||||
| 启动命令 | `python admin_backend.py --host 127.0.0.1 --port 8765 --db /www/wwwroot/xchat/backend.db --runtime-file /tmp/xchat_backend_runtime.json` |
|
||||
| 环境变量 | 选择“指定变量”,添加 `WECOM_ADMIN_INITIAL_PASSWORD=你的强密码` |
|
||||
| 启动用户 | `www` |
|
||||
| 安装依赖包 | 留空,后台没有第三方依赖 |
|
||||
|
||||
如果项目实际解压在 `/www/wwwroot/wechat-backend`,启动命令中的 `/www/wwwroot/xchat` 也要改成 `/www/wwwroot/wechat-backend`。
|
||||
|
||||
确认项目目录允许 `www` 用户写入,否则后台无法创建 `backend.db`。在宝塔“文件”中把目录所有者设为 `www` 即可。
|
||||
|
||||
提交后打开项目日志。正常日志应包含:
|
||||
|
||||
```text
|
||||
配置后台已启动:http://127.0.0.1:8765
|
||||
```
|
||||
|
||||
然后在宝塔终端验证:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8765/health
|
||||
```
|
||||
|
||||
如果返回包含 `"status": "ok"` 的 JSON,说明 Python 项目已经启动成功,可以直接跳到“创建网站和 SSL”。
|
||||
|
||||
## 四、使用 Docker 启动(备选)
|
||||
|
||||
在宝塔“Docker”中安装 Docker;也可以直接在宝塔“终端”执行下面的命令:
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/wechat-backend
|
||||
docker compose --env-file .env up -d --build
|
||||
```
|
||||
|
||||
检查容器状态:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env ps
|
||||
curl http://127.0.0.1:8765/health
|
||||
```
|
||||
|
||||
健康检查应返回包含 `"status": "ok"` 的 JSON。若构建失败,在宝塔“Docker → 容器编排”或终端中查看日志:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env logs --tail=100 backend
|
||||
```
|
||||
|
||||
## 五、在宝塔创建网站和 SSL
|
||||
|
||||
1. 进入“网站”,添加一个纯静态站点,域名填写准备好的二级域名。
|
||||
2. 打开该站点的“SSL”,申请 Let's Encrypt 或 LiteSSL 证书。
|
||||
3. 确认证书正常后开启“强制 HTTPS”。
|
||||
4. 云服务器安全组和宝塔防火墙只需放行 `80`、`443`,不要放行 `8765`。
|
||||
|
||||
建议先申请证书,再配置反向代理,避免 HTTP 文件验证被代理规则影响。
|
||||
|
||||
## 六、配置反向代理
|
||||
|
||||
打开站点设置中的“反向代理”,新增一条规则:
|
||||
|
||||
| 宝塔字段 | 填写内容 |
|
||||
| --- | --- |
|
||||
| 代理名称 | `wechat-backend` |
|
||||
| 目标 URL | `http://127.0.0.1:8765` |
|
||||
| 发送域名 | `$host`,没有该选项时保持默认 |
|
||||
| 内容替换 | 留空 |
|
||||
| 缓存 | 关闭 |
|
||||
|
||||
保存后访问:
|
||||
|
||||
```text
|
||||
https://ai-admin.example.com/health
|
||||
```
|
||||
|
||||
能够看到健康检查 JSON 后,再访问域名首页登录后台。
|
||||
|
||||
## 七、首次登录和桌面端连接
|
||||
|
||||
1. 后台网页用户名为 `admin`。
|
||||
2. 密码是 Python 项目环境变量或 `.env` 中设置的 `WECOM_ADMIN_INITIAL_PASSWORD`。
|
||||
3. 首次登录会要求立即修改密码。
|
||||
4. 在后台发布模型配置。
|
||||
5. 在“桌面端版本升级”中设置当前正式版本、升级包下载地址和更新说明。
|
||||
6. 桌面端固定从云端域名获取配置,无需在本机填写后台地址。
|
||||
|
||||
版本号使用 `1.0.0` 这种格式。云端版本与桌面端版本不一致时,软件启动会提示升级:
|
||||
|
||||
- 未开启“强制升级”:用户可以立即升级,也可以稍后继续使用。
|
||||
- 开启“强制升级”:用户只能前往下载或退出软件,不能进入主界面。
|
||||
|
||||
开启强制升级前必须先填写一个可正常打开的 `http://` 或 `https://` 下载地址。当前桌面端版本可在软件窗口标题中查看。
|
||||
|
||||
发布新的桌面安装包时,还要把项目 `app_version.py` 中的 `APP_VERSION` 改成新版本号再打包。例如新安装包是 `1.0.1`,云端“最新版本号”和安装包内的版本号都应填写 `1.0.1`。
|
||||
|
||||
## 八、更新和备份
|
||||
|
||||
更新后台前,在宝塔终端执行:
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/wechat-backend
|
||||
docker compose --env-file .env stop backend
|
||||
mkdir -p /www/backup/wechat-backend
|
||||
docker cp wechat-config-backend:/data/backend.db /www/backup/wechat-backend/backend.db
|
||||
docker compose --env-file .env start backend
|
||||
```
|
||||
|
||||
然后覆盖新的 `admin_backend.py` 并重新构建:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env up -d --build
|
||||
```
|
||||
|
||||
不要执行 `docker compose down -v`,它会删除保存数据库的数据卷。
|
||||
|
||||
## 九、常见问题
|
||||
|
||||
- 日志提示 `hashlib has no attribute pbkdf2_hmac`:旧部署包不兼容宝塔中缺少 OpenSSL PBKDF2 的 Python 环境。上传最新版 `admin_backend.py` 后重启项目。
|
||||
- 域名返回 `502 Bad Gateway`:后台容器未启动,或 `127.0.0.1:8765` 健康检查失败。
|
||||
- 证书申请失败:确认域名已经解析到服务器;暂时关闭反向代理后重新申请。
|
||||
- 桌面端提示无法连接:必须填写完整的 `https://后台域名`,并确保证书受系统信任。
|
||||
- 修改 `.env` 密码没有效果:初始密码只在第一次创建数据库时使用,后续密码请在后台网页中修改。
|
||||
- 修改版本策略后桌面端没提示:确认桌面端程序已更新到包含版本检查的新版本,并重新完整退出、打开软件。
|
||||
- 忘记管理员密码:先停止后台,再使用 Compose 临时任务运行密码重置命令,完成后重新启动:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env stop backend
|
||||
docker compose --env-file .env run --rm backend python /app/admin_backend.py --db /data/backend.db --reset-admin-password
|
||||
docker compose --env-file .env start backend
|
||||
```
|
||||
|
||||
通用部署方式见 [BACKEND_DEPLOY.md](BACKEND_DEPLOY.md)。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 联系人验证自动跳过(1.4.13)
|
||||
|
||||
出现“开启了联系人验证/你还不是联系人/请先发送联系人验证请求”等系统提示时,
|
||||
取消该会话的待回复任务,记录“跳过”,继续处理其他客户。
|
||||
不点击联系人验证链接,不自动加好友,不将失败消息记录为发送成功。
|
||||
|
||||
视觉模式:仅检查当前已核对会话的消息区域,结合 OCR 文案、居中位置及灰色提示文字识别。
|
||||
在生成回复前、发送前、已发送但待核对回执时检查。清理待回复、扫描进度及未提交交换记录。
|
||||
沿用 6 小时不可回复缓存,重启后有效;到期重新检查。网络错误、单独红叹号、普通客户文字
|
||||
提到“联系人验证”不会直接使会话进入这项跳过规则。
|
||||
|
||||
协议模式:读取系统消息或明确的客户端联系人拒发原因,按账号+会话 ID 保存限制状态。
|
||||
跳过记录与协议队列一起持久化。明确“验证通过/添加成功”系统消息解除限制,之后的新消息
|
||||
可正常建单。遇到无明确联系人限制证据的未知回执,仍保留原有禁止自动重发的保护。
|
||||
|
||||
验证:
|
||||
- 用户提供的截图通过实际 OCR+完整识别规则。
|
||||
- 本机已有消息缓存只读校验:识别到 9 条 content_type=1011 的同类系统通知。
|
||||
- 发送/回执/队列回归 592 项通过;最终针对性回归 76 项通过(包含重叠测试,不相加)。
|
||||
- 本次未向真实会话发送消息、未请求好友验证。
|
||||
- 补充失败气泡优先检查后,最后一轮发送/队列回归 458 项通过;不会将拒发气泡误记成功。
|
||||
- 完整构建与成品扫描结果见 release_acceptance_v1.4.13.json。
|
||||
|
||||
本功能不改变此前协议通道的进程内发送实现;该实现的 Defender 行为复核仍待完成。
|
||||
1.4.12 的微软提交未成功,工具启动时发生沙箱 ACL 错误;没有微软已通过的结论。
|
||||
|
||||
最终安装包已生成(352,092,803 字节),打包自检通过。Defender 1.459.213.0 对完整应用目录和安装包均报告 found no threats。
|
||||
安装包 SHA256:265e69b4e8ad02f0f8d90f92be182f6c0e7e8f0e6f4c7a42c3dc4cf60b82b4f7。
|
||||
安装新版本后功能生效;未自动替换正在运行的旧程序。文件扫描通过不替代原协议行为告警复核。
|
||||
@@ -0,0 +1,49 @@
|
||||
> v1.4.15 更新:两个版本已整合到主程序,5.0.11 首次发送验证在同一界面完成,取得真实回执后启用当前账号。最新说明见 [DUAL_VERSION_COMPATIBILITY.md](DUAL_VERSION_COMPATIBILITY.md)。以下内容保留此前阶段的验证记录。
|
||||
|
||||
# 跨电脑安装与协议环境检查(v1.4.14)
|
||||
|
||||
本次修复安装位置、首次运行和错误提示问题。视觉回复、原账号协议回复、联系人验证自动跳过均保留。
|
||||
|
||||
## 位置识别
|
||||
|
||||
- 企业微信运行时,读取实际进程映像路径,不使用开发电脑的固定 C 盘路径。
|
||||
- 企业微信未运行时,读取当前用户及系统的 32/64 位注册表 Executable 项,并检查存在的程序文件。
|
||||
- 聊天数据目录与程序安装目录分别处理:支持已保存目录、明确指定的环境变量目录、企业微信登记的数据位置、Windows 重定向文档和 OneDrive。
|
||||
- DataLocation 为数字标志时不会误当成目录。
|
||||
- 自定义目录没有登记或自动识别失败时,协议页可以选择 WXWork、账号目录或 Data 目录;保存到当前 Windows 用户配置中。
|
||||
- 数据目录匹配当前登录账号,避免历史账号的数据库可读就误报当前账号就绪。
|
||||
- 安装包不携带开发电脑的账号密钥或聊天数据库;需要时在目标电脑初始化并校验本机密钥。
|
||||
|
||||
## 启动及提示
|
||||
|
||||
保存协议模式后重启应用会自动检测;进入自动化设置也会重新检查过期的状态。首次初始化在后台执行,重复检测请求会合并。初始化期间请求开始监听会等待检查结果,停止操作会取消等待启动。
|
||||
|
||||
就绪状态要求客户端构建、登录账号和当前账号消息库全部通过。版本不匹配、x64 企业微信客户端、无法读取进程、多个已登录主进程、数据目录缺失和数据库不可读,会显示不同提示。原生协议适配器不依赖 WeClaw SDK 运行时,不再以“SDK 未连接”掩盖原生适配失败。
|
||||
|
||||
客户端可能在初始化期间退出、切换账号或升级,所以完成初始化后会再次核对进程和账号。环境检测不会发送消息;“发送/回执已实测”描述适配版本的已有记录,不能当成本机本次真实发送的证明。
|
||||
|
||||
## 支持范围和验证边界
|
||||
|
||||
- 桌面安装包为 Windows 64 位构建;本次实际验证系统为 Windows 11。
|
||||
- 当前原账号协议适配范围仍是企业微信 **5.0.10.6023 x86 的已验证构建**。保留原有 SHA-256 和入口校验,不将未知版本冒充已适配。
|
||||
- 企业微信装在其他盘、不同用户名或含空格/中文的目录,不要求改成开发电脑的路径。
|
||||
- 不代表已适配所有 Windows 版本、CPU 架构或企业微信版本。尚未访问用户的另一台物理电脑;其截图没有提供检测后的客户端版本和具体错误。
|
||||
- 原协议发送实现未更改,历史 Defender 行为告警的厂商复核仍未完成。本次不会通过关闭保护、添加排除或更换注入方式处理兼容性。
|
||||
|
||||
## 已完成的源码验证
|
||||
|
||||
- 687 项相关回归通过,含跨目录、当前账号数据库、加密库首次初始化、协议与视觉流程、联系人验证跳过及打包资源检查。
|
||||
- 自定义 D 盘程序路径通过合成进程元数据测试;中文、空格及迁移后的数据目录使用实际临时文件测试。
|
||||
- 本机只读实测:识别当前企业微信路径、5.0.10.6023 x86、登录账号及消息库,返回 native_ready;本次没有发送消息。
|
||||
- 测试日志:tmp/compatibility-final-regression.log。
|
||||
- 本机只读诊断:tmp/compatibility-local-probe.json。
|
||||
- 后续成品迁移验证、扫描和安装包哈希写入 release_acceptance_v1.4.14.json。
|
||||
|
||||
## 最终成品验证
|
||||
|
||||
- v1.4.14 安装包已生成:dist/installer/甄AI客服-安装包-v1.4.14.exe。
|
||||
- 完整应用独立复制到含中文和空格的新目录,使用全新 LOCALAPPDATA、移除 Python 等外部工具目录的 PATH,运行成品自检退出码为 0;复制前后主程序哈希一致。
|
||||
- 已核对成品中包含环境识别、当前账号数据库门槛、协议与视觉通道和联系人验证跳过模块;没有打入私有密钥文件或聊天数据库。
|
||||
- Defender 对最终主程序目录和安装包文件扫描均未检出威胁。此结果不代表原生协议行为告警已获厂商复核通过。
|
||||
- 安装包大小 352092520 字节,SHA-256:09cb1fbd1d446abd69f40139b78f99195fad99ddc393ff6e567cdce81759dbd1。
|
||||
- 完整验证记录:release_acceptance_v1.4.14.json。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 双版本安装包 v1.4.15
|
||||
|
||||
同一个安装包包含企业微信 5.0.10.6023 x86 和 5.0.11.6018 x86 的独立接口布局。运行时按实际进程路径、客户端文件 SHA-256 和接口结构选择对应版本;无需统一安装盘符或手工选择版本。视觉回复、原账号协议回复、联系人验证自动跳过均保留。
|
||||
|
||||
| 企业微信版本 | 启用方式 | 实测状态 |
|
||||
| --- | --- | --- |
|
||||
| 5.0.10.6023 x86 | 检测当前账号与消息库后启动 | 有历史真实发送和服务器回执记录 |
|
||||
| 5.0.11.6018 x86 | 在主程序中完成首次发送验证,取得真实回执后启用当前账号 | 开发机不能升级,尚无本次新版实机回执;不能把代码测试视为实机验收 |
|
||||
|
||||
首次发送验证位于“自动化设置 → 协议模式”,环境检测通过后显示按钮。用户输入当前账号已有的专用测试单聊,确认后只发送“协议通道测试,请忽略”。程序核对原账号、会话、内容及服务器消息编号,将确认结果写入本机发送日志,再重新检测环境。之后可直接启动协议回复,不需要单独运行验证包或回传报告来手工开通。
|
||||
|
||||
启用证据绑定客户端文件、接口修订和账号。导出的 JSON 报告、界面布尔值、失败记录、未知结果以及另一账号的回执均不能启用新版本。未取得证据前,默认发送入口甚至不会申请客户端写入权限;只有明确的单次测试可以进入验证调用。每次实际发送仍执行账号、服务、会话和回执核对,结果不明不自动重发。
|
||||
|
||||
## 构建与检查
|
||||
|
||||
- 664 项相关回归通过,覆盖双版本调用约定、确认回执启用、跨账号拒绝、界面按钮与后台验证、联系人限制、视觉流程、数据库和打包路径。日志:tmp/dual-version-v1.4.15-regression.log。
|
||||
- 安装包仍是 Windows 64 位桌面应用;这里的 x86 指企业微信客户端。未适配其他企业微信版本或 x64 企业微信。
|
||||
- 未取消历史实测门槛。直接开放未验证版本自动发送的改动曾被自动审批拒绝,原因是缺少真实收发验收,可能错误调用或错发;该改动未执行。本版采用明确的首次测试和真实回执作为启用条件。
|
||||
- 杀毒软件行为告警的微软复核仍未完成;文件扫描和功能测试不能替代行为复核。程序不会关闭防护或添加排除。
|
||||
- 成品自检、扫描和哈希记录在 release_acceptance_v1.4.15.json;该记录不声称开发机已经实测 5.0.11 的真实收发。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 协议版与知识中心整合
|
||||
|
||||
2026-09-16 将 C:\kefu 的近期功能整合到 C:\wechat_rpa。保留协议版 v1.4.16 的客户端、视觉/协议模式、原账号发送、回执去重、联系人验证跳过及企业微信版本适配。
|
||||
|
||||
## 已接入
|
||||
|
||||
- 智能体角色、规则与全部授权账号视图:原有后端能力保留,补齐完整管理前端。
|
||||
- 归档性能:分页与游标、租户过滤、账号统计按需读取、导出查询优化。
|
||||
- 知识加工:范围预览、规则/指定模型整理、任务进度、暂停/继续/取消、失败换模型续跑与增量整理。
|
||||
- 异常处理:模型字段异常保留脱敏问答为待审草稿,已驳回知识支持删除。
|
||||
- 全量审核:指定账号或全部授权账号,跨全部页面后台处理;通过后进入待发布,异常保留原因。
|
||||
- 检索:只引用有效且已发布的知识;账号隔离、来源与版本复核、引用留痕、可读错误提示。
|
||||
- 协议回复:经过模型网关的回复接入知识检索,保留任务 ID、知识引用和裁判风险;高风险回复进入原有审核流程。
|
||||
- 客户端「管理知识」打开后台知识中心,数量来自云端已发布知识。知识入口使用正确的 hash 路由。
|
||||
|
||||
## 使用方式
|
||||
|
||||
使用当前已配置的后台时,退出并重新启动本目录的源码客户端;在客户端同步后台配置后,进入「AI 人格与能力 → 管理知识」。选择账号,整理聊天、审核、发布,再开启该账号「客服使用知识」。协议模式通过该账号的模型网关使用知识,本机直连模型不经过服务端知识检索。
|
||||
|
||||
在管理端「知识与审核」可一键审核此账号或全部授权账号,不需要逐页全选。审核通过仅为待发布,发布后才会参与检索。
|
||||
|
||||
本目录也能独立运行完整后台:
|
||||
|
||||
```powershell
|
||||
cd C:\wechat_rpa
|
||||
python run_backend.py --db C:\wechat_rpa\backend.db
|
||||
```
|
||||
|
||||
启动管理 API、模型网关、知识 Worker。管理页面位于 `http://127.0.0.1:8766/#/knowledge/center`,无需为前端另起开发服务。已保留当前配置和数据库;本次验收仅使用临时测试数据库,没有启动此命令处理真实任务。
|
||||
|
||||
前端源码与生产资源位于 `admin-web`。再次修改前端时先安装其 pnpm 工作区依赖,再执行 `pnpm --filter @vben/web-antd typecheck`、`pnpm --filter @vben/web-antd build`。已附带本次通过浏览器验收的 dist。
|
||||
|
||||
Linux 服务端部署入口为 [deploy/im-admin/README.md](deploy/im-admin/README.md)。知识功能说明见 [KNOWLEDGE.md](KNOWLEDGE.md)。
|
||||
|
||||
## 验证
|
||||
|
||||
- 知识、任务、模型输出、全量审核、检索和网关:96 项通过。
|
||||
- 新增协议与知识联动、独立管理端及桌面状态:7 项通过。
|
||||
- 管理后台、归档、性能、智能体和路由:326 项通过。
|
||||
- 协议、联系人、版本适配、打包规则和 Qt:221 项中 217 项通过,4 项因未安装可选 Unicorn CPU 模拟器跳过。
|
||||
- 客户端登录、归档上传、回复安全、防自回复:496 项通过。
|
||||
- 合计 1142 项通过、4 项跳过。协议发送使用模拟传输,没有向真实客户发送测试消息。
|
||||
- 独立管理页:正常检索仅命中已发布知识;空白、单字、超长问题与后端错误显示正确,无页面脚本异常。
|
||||
- 跨账号页面验收:77 条模拟草稿中 75 条通过进入待发布、2 条保留异常原因,发布数仍为 0;关闭弹窗后任务继续。
|
||||
|
||||
## 交付与回退
|
||||
|
||||
本次交付是源码、前端生产资源和部署配置。现有 dist 中已打包的 EXE/安装包未重建,线上服务器未部署;已有运行中的进程需重启才加载新源码。
|
||||
|
||||
修改前文件备份:`backups/integration-20260916-151604`。改动清单与 SHA-256 见 `INTEGRATION_MANIFEST_20260916.json`,测试结果见 `INTEGRATION_VERIFICATION_20260916.json`。原数据库、密钥、聊天归档和个人配置没有被本次整合覆盖。
|
||||
|
||||
需要回退时先停止使用本目录源码的服务和客户端,再按清单恢复备份中的原文件。不要覆盖运行数据。知识功能使用后不要恢复旧数据库,否则会丢失之后新增的聊天与知识。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"date": "2026-09-16",
|
||||
"target": "C:\\wechat_rpa",
|
||||
"passed": 1142,
|
||||
"skipped": 4,
|
||||
"skip_reason": "Optional Unicorn CPU emulator not installed",
|
||||
"suites": {
|
||||
"knowledge_and_gateway": {
|
||||
"passed": 96
|
||||
},
|
||||
"protocol_knowledge_integration": {
|
||||
"passed": 7
|
||||
},
|
||||
"backend_archive_agents_router": {
|
||||
"passed": 326
|
||||
},
|
||||
"protocol_qt_packaging": {
|
||||
"passed": 217,
|
||||
"skipped": 4
|
||||
},
|
||||
"client": {
|
||||
"passed": 496
|
||||
}
|
||||
},
|
||||
"browser_search": {
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"空白、单字和超长问题在页面提示,不发送无效请求",
|
||||
"正常问题支持回车检索并去除首尾空白,只返回 1 条已发布知识,不命中草稿或待发布",
|
||||
"后端 422 数组错误显示中文说明,无对象字符串、不回显请求内容,失败后清除旧结果",
|
||||
"无已发布知识的账号正常返回 0 命中,并提示先发布"
|
||||
],
|
||||
"pageErrors": []
|
||||
},
|
||||
"browser_review": {
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"指定账号 37 条跨页审核:35 条进入待发布,2 条保留;关闭弹窗后后台继续,其他账号未修改",
|
||||
"全部授权账号覆盖剩余 42 条,40 条通过;累计 75 条待发布,0 条发布,异常原因可查看",
|
||||
"异常结果可直接跳转对应账号的知识详情"
|
||||
],
|
||||
"pageErrors": []
|
||||
},
|
||||
"artifacts": "C:\\kefu\\deploy\\protocol-integration-20260916",
|
||||
"live_customer_messages_sent": false,
|
||||
"live_server_deployed": false,
|
||||
"installer_rebuilt": false
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# 聊天知识中心
|
||||
|
||||
本模块将归档中的单聊文本整理为待审知识,审核发布后由现有模型网关检索引用。知识默认不启用、草稿不会进入检索,原始归档保留。
|
||||
|
||||
## 首次使用
|
||||
|
||||
1. 更新管理 API、模型网关与前端,启动知识 Worker。
|
||||
2. 管理端进入「知识中心 → 知识加工与检索」,选择具体客户端账号。
|
||||
3. 点击「从聊天整理知识」,选择该租户的客服来源账号。建议先设置消息上限 10000,采用规则整理。
|
||||
4. 在「加工任务」查看进度,在「知识与审核」逐条核对来源、事实、身份、脱敏结果和适用条件。
|
||||
5. 保存草稿、审核通过、发布。健康或个体医疗信息需要专业审核并通用化,不能将患者个人情况当作普遍结论。
|
||||
6. 在「检索测试」检查真实问题的召回结果,再开启该账号的「客服使用知识」。未命中时,模型会收到追问、不得编造业务事实的要求。
|
||||
7. 通过「引用记录」查看网关每次使用的知识 ID、版本和检索耗时。发布后的编辑会退回草稿;旧版本可恢复为新草稿,重新审核发布。
|
||||
|
||||
整理、编辑、发布与检索测试选择具体账号;全量审核支持全部授权账号并按租户分别处理。「全部账号」也提供授权范围内的汇总视图。知识不跨租户共享。
|
||||
|
||||
## 第二版:整理与审核流程
|
||||
|
||||
- 「全部账号」下可以点击「选择账号开始整理」,展开账号选择;选择具体账号后进入整理向导。
|
||||
- 先预览所选来源与日期内的单聊消息量、会话数、身份明确的有效文本量,以及本次最多读取条数。预览不创建任务,不调用模型,不返回聊天正文。
|
||||
- 预览的有效文本条数覆盖整个选定范围,不代表本次一定生成的知识数。新任务在创建时重新确定截止水位和消息总数;预览后新增的归档可能使数字变化。
|
||||
- 新任务展示「已读 / 实际计划消息数」、来源、整理方式和完成原因。旧任务缺少总数时只展示已读条数,不使用配置上限冒充实际总数。
|
||||
- 支持暂停、继续和取消。取消只停止后续处理,已经生成的草稿保留;已发起的模型请求可能仍计费。取消某个任务不会自动关闭该来源的持续整理开关。
|
||||
- 相同范围、整理方式与处理上限的任务在排队、处理中或暂停时,不能重复创建。可以继续原任务或取消后重建。
|
||||
- 在知识列表勾选当前页的条目,进入批量审核。每条需要展开查看脱敏来源,核对事实和适用条件后分别勾选;缺少条件、版本变化或状态不符会单独提示。
|
||||
- 支持批量发布、驳回、停用。每条分别调用现有鉴权接口、检查当前版本与来源并写入审计;操作不是一个整批事务,成功项保留,失败项逐条显示且不自动重试。
|
||||
- 批量操作期间可以停止后续请求;已经发起的操作会完成。离开页面也会停止后续请求。刷新后按实际状态处理未完成项。
|
||||
- 已过期知识不能审核通过或发布;只有已发布知识可停用。账号至少有一条有效、来源未失效的已发布知识,才能开启客服使用知识。
|
||||
- 修复了消息上限恰好等于完整范围末尾时丢弃最后一个问答的问题。若上限确实截断后续上下文,末尾未闭合问答仍会丢弃。
|
||||
|
||||
本次任务统计保存在已有 `options_json`,不新增数据库列。旧任务、已有知识和来源追溯保持兼容。
|
||||
|
||||
## 启动与部署
|
||||
|
||||
本地开发:
|
||||
|
||||
```powershell
|
||||
cd C:\wechat_rpa
|
||||
python run_backend.py --db backend.db
|
||||
```
|
||||
|
||||
启动器现在同时拉起管理 API、模型网关、知识 Worker。单独运行 Worker:
|
||||
|
||||
```bash
|
||||
python knowledge_worker.py --db /data/backend.db
|
||||
# 只执行一个批次,可用于诊断
|
||||
python knowledge_worker.py --db /data/backend.db --once
|
||||
```
|
||||
|
||||
本目录的完整部署入口使用根目录 `deploy/im-admin/compose.yaml`,已增加 `knowledge-worker` 服务,并复用现有镜像和数据库卷。不要使用已经过时的 `backend_deploy` 配置替代当前部署。
|
||||
|
||||
更新前备份 SQLite(使用 SQLite backup API 或停止写入后备份数据库及其密钥),保留当前镜像作为回退。构建前端后,在源码根目录执行(详见 `deploy/im-admin/README.md`):
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/im-admin/compose.yaml up -d --build api gateway knowledge-worker
|
||||
docker compose -f deploy/im-admin/compose.yaml logs --tail=100 knowledge-worker
|
||||
```
|
||||
|
||||
API 启动时新增知识表、FTS5 表、来源失效触发器和归档扫描索引;首次索引构建时间取决于数据库与磁盘。建议低峰部署。现有管理角色自动获得新权限,其他角色需配置 `knowledge:read/write/review/publish/export`;加工和审核同时要求 `im:content:read`。
|
||||
|
||||
关闭「客服使用知识」即可回退到原回复链路。回滚代码前停止 Worker,保留新增表与数据,避免恢复旧库覆盖部署后的新增归档。
|
||||
|
||||
## 整理与增量更新
|
||||
|
||||
- 规则整理不调用模型。按单聊、连续发言及 30 分钟间隔整理完整问答,跨读取批次保留上下文。
|
||||
- 只接受明确的客服来源账号;发送者身份还必须与来源账号匹配。群聊、未知方向、非文本、撤回消息和不完整片段不会自动产生可用知识。
|
||||
- 每批规则任务读取最多 200 条,模型任务最多 20 条,使用游标和租约。任务、游标、草稿在数据库中持久化;Worker 崩溃后租约最长 5 分钟到期,可继续处理。
|
||||
- 暂停会使在途批次失去提交权限。已经开始的模型请求不能撤销,重试可能产生额外调用,因此调用预算按发起前预扣。页面展示调用次数和字符量,不将字符数冒充 token 数或费用。
|
||||
- 模型整理可指定管理端已启用的文本模型,创建时固定所选出口;仅发送规则预处理后的脱敏问答。输出始终为待审草稿,不能自动发布。
|
||||
- 精确重复问答去重;同义问法和相互冲突的不同答案仍需审核。暂未实现自动语义合并。
|
||||
- 任务固定截止时间并读取该时点的消息版本。上限截断时,最后一个未闭合问答会丢弃;增加范围重新加工可补齐,已生成草稿会去重。
|
||||
- 创建任务时勾选「持续整理」后,该来源账号后续的消息插入或内容/状态变化会写入增量队列。会话静默 30 分钟后,Worker 只重新整理变化会话,使用规则方式生成草稿,不产生自动模型费用。可在加工任务页停用或恢复。
|
||||
- 撤回、修改、删除消息时,关联知识立即标为来源失效,并删除关键词索引。向量库中的旧点即使尚未清理,也不能通过 SQL 租户、状态、版本和来源复核。重新加工、审核后才可再次使用。
|
||||
|
||||
## 检索配置
|
||||
|
||||
默认关键词检索使用 SQLite FTS5,对中文采用双字切分,英文采用词切分,不扫描整个归档。它无法完整覆盖同义表达;需要更好的语义召回时配置向量服务。
|
||||
|
||||
在 `deploy/im-admin/.env` 配置以下变量,重建 API、网关和 Worker 的容器环境:
|
||||
|
||||
```dotenv
|
||||
KNOWLEDGE_QDRANT_URL=http://your-qdrant:6333
|
||||
KNOWLEDGE_QDRANT_KEY=
|
||||
# 完整 embeddings API 地址,不会自动补路径
|
||||
KNOWLEDGE_EMBEDDING_URL=https://your-provider.example/v1/embeddings
|
||||
KNOWLEDGE_EMBEDDING_MODEL=your-embedding-model
|
||||
KNOWLEDGE_EMBEDDING_KEY=your-secret
|
||||
```
|
||||
|
||||
向量接口需支持 `{model,input}` 请求及 `data[0].embedding` 响应。URL、模型、密钥只从服务端环境读取。发布知识时完成向量写入后才置为已发布;配置不完整或服务失败会阻止本次发布,不会假装索引已完成。未配置语义服务时,可正常发布为关键词知识。
|
||||
|
||||
不同 embedding 模型/地址使用不同集合,防止不同向量空间混用。更换模型或为已有关键词知识补建向量时:
|
||||
|
||||
```bash
|
||||
python knowledge_worker.py --db /data/backend.db --reindex --tenant ACTUAL_TENANT_ID
|
||||
```
|
||||
|
||||
重建期间现有关键词检索仍可用。查询使用关键词与向量结果融合,最多选 5 条且限制引用总长度;语义服务暂不可用时回退关键词,管理端显示回退状态。当前相关度阈值是初始值,需在真实业务评估集上校准。
|
||||
|
||||
只有网关的 `purpose=chat` 会检索,界面识别等内部用途不受影响。同一次网关请求的模型候选共享证据;不同工具轮会重新检索,不跨客户缓存检索结果。网关幂等缓存中的知识引用也会重新检查是否有效。
|
||||
|
||||
## 数据与追溯
|
||||
|
||||
- `knowledge_job`:任务、固定水位、处理游标、未闭合上下文、租约、用量。
|
||||
- `knowledge_item` / `knowledge_revision`:当前知识与每次编辑、审核、发布的版本快照。
|
||||
- `knowledge_source`:来源消息 ID、版本、角色及脱敏文本。
|
||||
- `knowledge_fts`:关键词索引;Qdrant payload 只保存条目 ID、租户和版本,不保存正文。
|
||||
- `knowledge_settings` / `knowledge_watch` / `knowledge_dirty`:启用状态及增量队列。
|
||||
- `knowledge_retrieval_log`:网关引用的条目与版本、处理方式、耗时;日志不等于客服消息已成功发出。
|
||||
|
||||
「导出已发布知识」提供 UTF-8 JSONL,包含 `schema_version=knowledge-v1`、ID、版本、问题、答案、适用条件、分类及有效期。导出不包含原始聊天或患者信息字段;只导出当前租户的有效已发布知识。
|
||||
|
||||
自动脱敏覆盖已知发送者姓名、手机号、身份证号、长编号、邮箱、链接和带标签的地址;它不是完整的个人信息识别器,自由文本地址、第三方姓名等仍必须人工检查。
|
||||
|
||||
## 验证与上线评估
|
||||
|
||||
```bash
|
||||
python -m unittest test_knowledge -v
|
||||
python -m unittest test_model_gateway test_archive_api test_admin_api
|
||||
```
|
||||
|
||||
前端:在 `admin-web` 执行 `pnpm --filter @vben/web-antd typecheck` 和 `pnpm --filter @vben/web-antd build`。
|
||||
|
||||
上线前先用独立问题集测试正确知识召回、回答准确性、无依据追问、权限隔离和延迟。相同客户、会话及近重复案例不应同时出现在知识构建样本与评估集。当前实现未处理语音、图片、附件,没有将生产 40 万条聊天自动送入模型,也没有执行模型微调。
|
||||
|
||||
|
||||
## 第三版:模型任务恢复与驳回删除
|
||||
|
||||
- 创建模型任务时选择整理模型、单次等待时间(30–180 秒,默认 90 秒)及调用总上限;知识整理请求输出上限为 4096 token,独立于实时客服调用配置。Dify 应用自己的模型配置与限制仍由上游控制。
|
||||
- 失败或暂停任务点击「配置 / 继续」,可换模型、增加等待时间或调用总上限,亦可改成规则整理。已完成草稿保留,新配置只作用于剩余问答。原模型停用时直接报错,不会偷偷切换到其他出口。
|
||||
- 扫描游标和待处理问答先持久化,每生成一条草稿立即提交。失败重试不再重做该批已成功的问答;在途请求遇到暂停、取消或进程中断仍可能计费并需要重试。
|
||||
- 错误区分超时、授权、限流、网络与输出格式问题,服务端不保存上游原始错误正文、密钥或地址。页面字符量统计成功处理的输入/输出,并非计费 token 数。
|
||||
- 已驳回知识支持单条及批量确认删除,需知识编辑与来源正文查看权限。删除知识、来源快照、版本记录和关键词索引,保留原始聊天归档及删除审计;无法从管理界面恢复。租户内保留内容指纹,避免完全相同的已删除草稿被再次生成;不同模型改写仍需人工去重。
|
||||
- 新增 `knowledge_discarded` 表,无需回填归档数据。任务待处理问答保存在私有 `state_json`,接口仅返回待处理数量。
|
||||
|
||||
回归检查:`python -m unittest test_knowledge test_knowledge_tasks test_model_gateway -q`;前端批量操作测试、类型检查及隔离浏览器中的失败换模型续跑与删除流程。
|
||||
|
||||
|
||||
## 模型字段异常修复(2026-09-16)
|
||||
|
||||
模型返回空标题、分类、适用条件或非标准类型不再使整项任务失败:标题从有效问题生成,分类保留待分类,适用条件留空并要求人工补充,类型在校验后采用标准值。
|
||||
标题可缩短至 160 字符;问题、答案和原始来源不静默截断。核心问答缺失、类型不正确、超过限制或 JSON 不完整时,该条转为保留原始脱敏问答的待审草稿,记录具体原因,后续候选继续处理。适用条件置空,未人工编辑补齐前不能审核或发布。格式异常次数、最近原因与输入输出字符量随单条结果一起保存。
|
||||
网络、授权、模型限流、调用预算耗尽仍会暂停并报错,避免把服务故障误当作可用模型结果。待处理的旧任务继续兼容,无需重建或清零进度。
|
||||
|
||||
验证:`python -m unittest test_knowledge test_knowledge_tasks test_knowledge_model_output test_model_gateway -q`,外加本机隔离浏览器验收。真实失败响应未保存,无法还原具体缺失字段;线上只读检查与修复验收不重发真实聊天。
|
||||
|
||||
|
||||
## 全量审核(2026-09-16)
|
||||
|
||||
知识与审核页支持指定账号或全部授权账号的一键审核,覆盖全部页面,不受当前搜索、状态筛选与勾选影响。对象是已经整理出的待审核知识草稿;原始聊天需先整理为草稿。审核通过只进入待发布,不发布、不启用客服检索、不调用模型。
|
||||
|
||||
流程:选择范围 → 预览待审/可通过/需处理数量与按账号统计 → 一次确认范围已完成必要审核 → 后台分批执行。预览以草稿 ID、租户、版本及更新时间固定快照,有效期 30 分钟;同一用户新预览使旧预览失效。之后新增、修改、删除、已驳回或已发布条目均不会被旧快照误审核。
|
||||
|
||||
SQL 检查必填及长度、适用条件、有效期、来源存在及最新版本。每批最多 100 条,状态、审核历史、审计、结果及进度在同一短事务内提交。后台在独立线程运行,不受模型网络调用阻塞;关闭页面、进程重启后按已提交进度继续,支持暂停、继续、停止。失败事务不会半批提交。
|
||||
|
||||
每批重查操作者的审核、知识读取、原文权限和账号授权。权限撤回或用户停用会暂停为需处理状态;某个账号授权撤回则跳过该账号。任务只对创建者可见,结果清单再次按当前账号权限分页过滤。未通过知识保持原状态并列出原因,可直接打开对应账号的知识详情。
|
||||
|
||||
表:knowledge_review_job、knowledge_review_target;应用启动自动创建。回滚旧应用保留这些表及任务进度,旧版不执行新审核队列,已完成审核保留为待发布。
|
||||
|
||||
接口(均要求 knowledge:read、knowledge:review、im:content:read):POST /api/v2/knowledge/review-tasks/preview?account_id=-1;GET /review-tasks;GET /review-tasks/{id};GET /review-tasks/{id}/results;POST /review-tasks/{id}/{start|pause|resume|cancel}。start 需 confirmed=true,重复提交不会重复入队,results 默认每页 30、最大 100。
|
||||
|
||||
验证:92 项后端回归,前端类型检查、生产构建、隔离浏览器跨页及跨账号流程。50 万条虚构知识预览 6.547 秒,连续 10 批共通过 1000 条、平均每 100 条 0.066 秒,Python tracemalloc 峰值 0.127 MB(不含 SQLite 原生内存和系统缓存),预览响应 636 字节;性能是本地模拟数据结果。线上部署验证不提交审核任务,不修改真实知识审核状态。
|
||||
|
||||
|
||||
## 检索输入与错误提示修复(2026-09-16)
|
||||
|
||||
旧版在空白或单字问题点击检索时,会把 FastAPI 422 校验数组直接交给消息组件,显示 `[object Object]`。本机浏览器使用旧构建复现了同样提示。
|
||||
|
||||
检索输入现在先去除首尾空白,按 Unicode 字符数校验 2–2000 个字符,前端拦截非法请求并给出中文说明;后端同步先去除首尾空白再校验。失败时清空旧结果并展示可读原因,不产生未处理的 Promise 错误。公共请求错误处理把字符串、FastAPI 数组及嵌套错误消息转为文字,不直接展示对象或校验响应里的原始 input。
|
||||
|
||||
检索仍只返回当前账号的已发布、有效知识;未发布知识不会因为此修复进入检索。当前账号没有已发布知识时展示「先审核并发布」说明,合法问题正常返回空结果。
|
||||
|
||||
验证:96 项后端回归,10 项前端错误格式与批量操作测试,前端类型检查和构建。隔离浏览器覆盖空白、单字、2001 字符拦截,正常问题回车检索及首尾空白处理,422 数组提示,旧结果清理与空知识库;模拟账号 3 条知识中仅 1 条已发布,正确命中 1 条。
|
||||
@@ -0,0 +1,150 @@
|
||||
> 2026-09-15 安全状态更正:1.4.11 虽然完成实发回执验证,随后被 Defender 以 Behavior:Win32/DefenseEvasion.A!ml 隔离。以下是功能验证历史,不能据此认定适合分发。详见 [安全复核记录](SECURITY_REVIEW_20260915.md)。
|
||||
|
||||
# 原账号协议自动回复:1.4.11 验收记录
|
||||
|
||||
2026-09-15,企业微信 Windows 5.0.10.6023 x86。
|
||||
|
||||
普通聊天的 MessageService 已实际调用成功。2026-09-15 09:51:01,以当前原员工账号“高兴亮”向现有“高瑞”会话发送一条“协议通道测试,请忽略”,获得客户端成功回调。数据库按账号、会话、原发送人和完整文本复核成功,消息 ID 570,服务器消息 ID 1023368。该回执表示发送成功,不代表客户已读。
|
||||
|
||||
正式 **1.4.11 EXE** 于 10:18:16 完成第二次真实发送验收:原账号“高兴亮” → 现有“高瑞”会话,文本“协议通道测试,请忽略”,消息 ID **571**,服务器消息 ID **1023370**。客户端返回成功,数据库验证原发送人和全文一致。
|
||||
|
||||
随后用同一 EXE 重放同一测试请求,返回完全相同的已确认回执;数据库前后仍只有上述两条测试消息,**新增重复消息为 0**。真实发送累计两条:一次研究原型,一次最终 EXE。未启动面向其他客户的自动监听。
|
||||
|
||||
最终构建完成 Qt WebEngine、干净 PATH 下 HTTPS/证书、自动解密、消息导航、视觉发送保护及数据库回复等随包自检。静态检查确认最终 EXE 包含上下文设置修正和正式协议模块,未包含研究注入原型。
|
||||
|
||||
EXE:`C:/wechat_rpa/dist/ZhenAI-WeCom-Assistant-v1.4.11/ZhenAI-WeCom-Assistant-v1.4.11.exe`。SHA-256:`36dfa102dba15aba618c246ce613a03fce6b0fbdce2fd3c2f1ed11a456b6878a`。
|
||||
验收证据:`native_protocol_acceptance_v1.4.11.json`、`tmp/packaged-native-send-v1.4.11.json`、`tmp/packaged-native-replay-v1.4.11.json`、`tmp/protocol-v1.4.11-final-build.log`。
|
||||
|
||||
## 已实现
|
||||
|
||||
- `wecom_native_sender.py`:当前登录身份检查、原有单聊消息服务调用、服务器回执与数据库复核、SQLite 持久化发送去重。
|
||||
- `wecom_native_stub.py`:离线生成的固定调用序列,运行时没有汇编器依赖。
|
||||
- `protocol_engine.py`:数据库增量文本监听、连续消息合并、AI 回复、审核放行、发送前会话变化检查、成功归档。
|
||||
- Qt 自动化设置:协议就绪状态、账号、自动/审核发送、合并与发送等待设置。
|
||||
- 协议队列保存在 `pending_replies.protocol.json`,发送结果保存在 `protocol_sends.sqlite3`。与视觉队列独立。
|
||||
- 发送结果不明确时暂停并保留任务,禁止自动重发;同一请求的成功回执跨重启复用。
|
||||
|
||||
最终在构建用 Python 3.12 中共 **149 项回归通过**:66 项协议/SDK/界面测试,以及83项共用设置与视觉队列回归。Python 3.11 的对应兼容性测试也已通过。隔离测试覆盖新消息作废旧回复、人工回复、同名会话、切换账号、取消、停止、审核、超时、重复调用和重启。
|
||||
|
||||
## 适用范围
|
||||
|
||||
需要企业微信保持登录,且本机消息数据库可以读取。当前适配的是已校验 SHA-256 的 5.0.10.6023 x86 客户端;客户端更新或入口改变会阻止发送。
|
||||
|
||||
本版自动处理和发送文本,单条最大 4096 UTF-8 字节。图片、语音等新消息留待人工处理。启动后处理新到的消息及原有协议待办,不批量回复历史聊天。
|
||||
|
||||
调用发生在运行中的企业微信消息服务,使用临时分配的一次性调用缓冲区;不操作鼠标、键盘或聊天输入框,不修改企业微信原有代码。它依赖本机企业微信客户端,不是可脱离客户端登录的独立网络协议实现。
|
||||
|
||||
之前的内置 WeClaw MCP 业务接口仍受灰度限制;本版使用经实际验证的普通聊天消息服务。以下记录是之前不同路径的历史测试结论。
|
||||
|
||||
---
|
||||
## 2026-09-15 最新实测:原生连接已通,业务接口未开放
|
||||
|
||||
用户在明确知悉通道密钥读取和独立代理一次性代码执行方式后回复“全部授权”,本次重新提交的工具执行已获批准。仅对自己创建的隔离官方代理执行握手代码,主企业微信进程只读40字节本地通道状态,没有写入或修改主进程。通道密钥未写入报告。
|
||||
|
||||
实际结果:
|
||||
|
||||
- `initialize` 成功:HTTP 200,`weclaw-mcp / 1.0.0`,协议版本 `2025-03-26`。
|
||||
- `tools/list` 成功:返回7个工具,包含 `wecom_tool`。
|
||||
- 工具清单声明的 `arguments.data` 返回参数错误;代理实际使用的 `arguments.payload`(内含 `headers_json`、`arguments_json`)可进入业务响应。
|
||||
- 使用完整官方代理请求头,`openclaw/get_user_initialization_info` 和最近一天 `msg/get_msg_chat_list` 均返回 **-90020005 / user not in gray**。帮助请求也返回同一错误。
|
||||
- 错误位于 HTTP 200 内多层 JSON 的 `data.error`,HTTP成功不能视为业务成功。
|
||||
- 当前尚未通过原账号身份、目标会话、发送和回执验收;没有向“小迷糊”或“高瑞”发送测试消息。
|
||||
|
||||
当前证据表明账号未进入该接口开放范围,不能用已成功的握手宣称协议自动回复可用。未修改功能准入开关。需要账号获得该接口的开放资格后,再进行业务验收。
|
||||
|
||||
成功测试代理PID为3252、3460、11596、13112,均由本次研究创建并已退出;进程监视器记录中的这些代理启动不代表客户端自行启动了智能体。
|
||||
|
||||
已新增 `wecom_native_protocol.py`:解析真实嵌套业务错误,并提供不含密钥和聊天正文的历史诊断。界面分别显示SDK运行时与“原生MCP上次实测”,明确记录时间、适用版本和非实时状态;历史报告不能启用发送。研究注入原型保持在 `tmp` 中,没有并入普通检测流程或EXE。
|
||||
|
||||
证据:`logs/wecom-native-trial.json`、`tmp/official-agent-native-bridge/initialize-result.json`、`tools-list-result.json`、`identity-with-agent-header-result.json`、`chat-list-result.json`。
|
||||
|
||||
本次更新已打包为 **v1.4.10**:`C:/wechat_rpa/dist/ZhenAI-WeCom-Assistant-v1.4.10/ZhenAI-WeCom-Assistant-v1.4.10.exe`,需保留同目录 `_internal`。37项单元/Qt回归通过,打包自检及新EXE实机诊断通过;EXE SHA256:`9bb61f59be11799e0e096f4208c4cbec0920f6d4c931b30ebc7e46ddb91646a0`。打包内容检查确认研究注入脚本、实测历史和密钥未进入发布包;本机脱敏历史单独保存在应用数据目录。该版本更新的是诊断能力,协议自动回复仍未就绪。
|
||||
|
||||
以下为先前分析记录,其中“未执行/待授权/待连接”描述只适用于对应历史阶段。
|
||||
|
||||
---
|
||||
|
||||
# 企业微信本机加密协议实测
|
||||
|
||||
日期:2026-09-14。目标:以原员工账号回复已有客户;授权测试对象为“小迷糊”或“高瑞”,文本为“协议通道测试,请忽略”。
|
||||
|
||||
**结果:已完成本地 TCP 加密结构的静态分析,并执行加密 MCP 初始化尝试;连接在发送请求之前就会被关闭。未建立 MCP 会话,没有获取消息工具或调用原账号发送接口,没有向测试客户发送消息。**
|
||||
|
||||
## 实际执行与结果
|
||||
|
||||
| 项目 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 客户端 | WXWork.exe 5.0.10.6023,x86;执行前核对整文件 SHA-256 |
|
||||
| 服务归属 | 9882、9883、50018、50010 回环监听归属当时的 WXWork.exe PID 12612 |
|
||||
| 本地加密状态 | 以只读权限读取已定位的 40 字节状态:密钥长度 32 字节、初始化标志 1;密钥不输出、不写入报告或文件 |
|
||||
| 加密初始化尝试 | 对 127.0.0.1:9882 发送 AES 加密的 HTTP/MCP initialize 请求,两个收发方向组合均未收到响应,连接被重置 |
|
||||
| 请求前检查 | 新建同端口 TCP 连接后不发送任何字节,连接已关闭(recv 返回 EOF);证据记录 requestSent=false |
|
||||
| 登录与客户映射 | 没有获得在线协议身份或可调用会话接口,仍未验证 |
|
||||
| 真实发送、回执 | 未执行、未验证;不得计入已发送或协议可用 |
|
||||
|
||||
这次失败发生在应用请求前,不能用“JSON 参数不正确”“缺少 chatid”解释。静态代码存在来源进程和签名检查,与观察到的立即关闭行为相符;没有运行时日志证明具体拒绝分支,不能把该推断写成已确定的错误码。
|
||||
|
||||
## 已定位的实现
|
||||
|
||||
程序 SHA-256:`47b5dfafc8def0b41a648610f1ba08bbf15f710c6f81c08d36c485bd82ce2842`。以下虚拟地址基于 PE 首选 ImageBase 0x400000,仅适用于本文件;读取进程状态时按实际模块基址换算 RVA。
|
||||
|
||||
- 0x10ec6a3:给服务内的字节数组分配 0x20 字节,调用 libcrypto RAND_bytes 生成本地通道密钥。
|
||||
- 0x1436bea:将 32 字节密钥复制到全局本地 TCP 状态,并写入长度 32 与初始化标志 1。
|
||||
- 0x1436a08、0x1436b50:Windows BCrypt 的 AES 分组加密与手动计数器/XOR 处理;按 16 字节分组,处理累计字节偏移。算法结构为 AES-256-CTR。
|
||||
- 0x1436c79、0x1436cc4:维护独立的两个 64 位方向字节计数器。初始化向量常量为 WECLAW_SEND_IV_1、WECLAW_RECV_IV_2。
|
||||
- 0x15c9018、0x15c9125:HTTP server connection backend 的解密、加密路径分别使用上述两个方向。按服务端调用关系,外部客户端发送方向应使用 SEND_IV_1,接收方向使用 RECV_IV_2。
|
||||
- 0x33b6040 附近:WeClawService 的连接前校验,读取 weclaw_check_signature_on_connect(默认开启),查询来源 PID/程序路径并检查签名,包含结果缓存和返回分支。
|
||||
|
||||
加密算法依据来自静态反汇编,运行时仅核对了状态的长度和初始化标志。**没有成功的服务端响应用于完成整个加密传输的实机互通验收。**
|
||||
|
||||
## 可复查材料
|
||||
|
||||
- [只读加密初始化原型](tmp/probe_native_mcp.py):仅发 initialize,不含 tools/call 或客户发送命令。研究脚本锁定该程序哈希及本次 PID,不是生产发送器。
|
||||
- [加密尝试结果](tmp/native-mcp-encrypted-probe-result.json)
|
||||
- [请求前断连结果](tmp/native-mcp-pre-request-check.json)
|
||||
- [初始化反汇编](tmp/protocol-native-disassembly.json)
|
||||
- [密钥状态写入函数](tmp/protocol-key-setter-disassembly.json)
|
||||
- [计数器与加密函数](tmp/protocol-cipher-disassembly.json)
|
||||
- [真实收发调用位置](tmp/native-tcp-cipher-callers.json)
|
||||
- [来源进程检查代码](tmp/native-mcp-connection-policy.json)
|
||||
|
||||
分析依赖 capstone 5.0.9 安装在 tmp/protocol-analysis-deps,没有加入应用运行依赖。本轮未更改企业微信程序、未注入代码、未关闭来源校验、未重新打包 EXE。
|
||||
|
||||
历史 [官方 CLI 0.1.9 MCP 配置源码](https://github.com/WecomTeam/wecom-cli/blob/9eb7898b959861af879495e211e37431fa908f19/src/mcp/config.rs)使用机器人凭据生成签名请求,也不能直接替代当前员工账号。内置 weclaw-cli 的本地传输与该公开 CLI 不是同一个已经验证的入口。
|
||||
|
||||
## 对后续实现的含义
|
||||
|
||||
单纯在外部 Python 中组装和加密 HTTP 请求,当前实测不能接入本机消息服务。后续需要研究客户端认可的正常桥接入口或客户端内部调用路径,并验证在线身份、会话和业务作用域;接通前不应猜测方法名和客户 ID 发送。连接准入和真实消息发送是两个独立验收步骤,本轮尚未通过前者。
|
||||
|
||||
|
||||
## 继续验证:官方代理启动与真实请求格式
|
||||
|
||||
本轮成功在 `tmp/official-agent-bootstrap-probe` 中以独立工作目录启动安装包内腾讯签名的 `wecom-agent.exe`。使用 `WECOM_AGENT_WORKSPACE_DIR`、`WECOM_AGENT_LOG_DIR`、`agent-client-config.json` 的 `client.mcp_url` / `client.websocket_url` 指定本地模拟服务。代理启动、配置读取和请求封装已通过实际运行验证;该验证没有接入真实客户会话。测试进程已退出。
|
||||
|
||||
抓到的原生请求使用 `tools/call` -> `wecom_tool`,其 `arguments.payload` 是 JSON 字符串,内含 `headers_json` 与 `arguments_json`;后者编码成 `[服务名, 方法名, 参数JSON字符串]`。启动阶段观察到:
|
||||
|
||||
- `agent_resource/get_agent_resource_effective_list`
|
||||
- `memory/get_agent_memory`
|
||||
- `openclaw/get_user_initialization_info`
|
||||
- `graph_use/get_memory_md`
|
||||
- `memory/get_workai_tree`
|
||||
|
||||
这些是代理发给本地模拟服务的初始化/读取请求,不是已经调用成功的企业微信业务接口,更不是原账号发送验证。
|
||||
|
||||
进一步静态分析定位到官方 IronBox 启动链路,安装目录内包含 `wironboxd32/64.exe` 与 `wironboxh32/64.dll`。本机普通外部进程对 9882、9883、50018、50010 的连接均在发送应用数据前关闭。
|
||||
|
||||
### 未执行的下一步与审批限制
|
||||
|
||||
已编写 `tmp/native_agent_readonly_bridge.py` 草案,计划仅在自己创建的隔离代理子进程中执行一次性 Winsock 握手代码,主客户端仅以只读权限读取已定位的本地通道状态。原型目前只允许 `initialize`、`tools/list` 和 `ping`,没有消息发送命令。
|
||||
|
||||
**执行该草案的工具请求被自动审批拒绝,脚本没有运行。** 审批指出:该操作读取客户端内存中的加密密钥,并通过子进程代码写入/远程线程通过来源校验,属于认证失败后的会话材料探测与安全边界绕过,缺少用户对这一具体高风险手段的明确授权。不能以其他命令、程序或间接执行规避拒绝。此方法也可能被安全软件阻止或导致测试子进程崩溃,需要明确授权后才可再次提交执行审批。
|
||||
|
||||
当前只获得了“官方代理可独立启动”和“原生请求封装格式”的验证;真实加密 MCP 连接、员工身份、客户发送及回执均未验收。未重新打包 EXE,也没有向测试客户发送消息。
|
||||
|
||||
可复核文件:
|
||||
|
||||
- `tmp/official-agent-bootstrap-probe/result.json`:模拟服务收到的请求。
|
||||
- `tmp/native-normal-bridge-xrefs.json`:正常启动环境及签名检查的静态调用位置。
|
||||
- `tmp/native-service-port-readonly-check.json`:请求前断连检查。
|
||||
- `tmp/native-bridge-followup-result.json`:本轮机器可读状态。
|
||||
- `tmp/native_agent_readonly_bridge.py`:未执行的只读桥接草案。
|
||||
@@ -0,0 +1,18 @@
|
||||
# v1.4.16 协议监听进度修复
|
||||
|
||||
用户日志显示原账号连接成功,浮窗却仍显示“正在连接企业微信”。原因是协议线程只推送了运行状态,没有推送进度;浮窗也没有替换连接阶段的默认文字。
|
||||
|
||||
本版连接完成后显示“协议已连接 · 正在检查新消息”;完成一轮读取且没有待处理任务时,显示“协议监听中 · 等待新消息”。收到新消息、生成回复、发送和核对回执时同步实际步骤;待审核、处理异常和不确定回执各自显示对应状态。
|
||||
|
||||
运行日志增加“[协议收信]”和“[协议处理]”,不附带聊天正文;每分钟最多一条“[协议监听]”轮询统计。数据库读取失败和回执不明确时,不记录轮询正常。发送成功仍以原有服务器回执及消息库核对为准。
|
||||
|
||||
保留 5.0.10.6023 x86 和 5.0.11.6018 x86 的适配、原账号核对和首次发送回执验证规则。相同账号与相同客户端构建已有的有效验证记录可继续使用。仅处理启动后收到的新消息和已保存的待处理任务。
|
||||
|
||||
验证:698 项回归测试通过,覆盖协议队列、回执去重、审核、联系验证跳过、双版本适配、数据库读取与 Qt 浮窗。Qt 离屏渲染使用与主程序一致的字体,已核对连接到监听的文字变化。
|
||||
|
||||
这些测试使用模拟数据库、模拟发送器或静态分析,不产生真实客户消息。用户提供的日志能证明其电脑已连接且有首次发送验证记录,尚不能证明新消息已被自动回复。新日志用于区分未收到新消息、待处理、发送确认和错误。
|
||||
|
||||
交付检查:主程序自检、全新配置目录及中文空格路径迁移自检、NSIS 安装包 4011 个文件完整性检查均通过。Defender 对主程序目录和安装包的文件扫描未发现威胁;历史行为告警的厂商复核仍未完成,本次文件扫描不代表行为告警已解决。
|
||||
|
||||
安装包:C:\wechat_rpa\dist\installer\甄AI客服-安装包-v1.4.16.exe
|
||||
SHA-256:3c4a3b3415c359fa94c2c692f9b5e1f1a6be77716909a7cb1db20c50ce970764
|
||||
@@ -0,0 +1,307 @@
|
||||
# 企业微信原账号协议自动回复功能规划
|
||||
|
||||
日期:2026-09-14
|
||||
状态:检测、SDK 通信层、通道配置与 ID 上下文读取已实现;本机 SDK 运行时未连通,真实协议发送尚未验证
|
||||
代码基线:项目 APP_VERSION 为 1.4.9;本机企业微信 5.0.10.6023(x86),发送桥接尚未验证。
|
||||
本轮已实现范围、客户端证据与剩余工作见 [本机协议分析与实现状态](WXWORK_PROTOCOL_ANALYSIS.md)。下文保留完整目标,不能视为全部已实现。
|
||||
|
||||
## 1. 产品目标与首版范围
|
||||
|
||||
用户已明确:继续使用现在的员工账号,回复已经添加的客户,消息出现在原客户会话中。新增“协议模式”,与现有视觉模式共享 AI、审核、队列和档案能力。
|
||||
|
||||
首版采用“数据库增量读取 + 已登录客户端收发能力桥接”。界面称为“协议模式”,旁注“需要企业微信客户端在线”。桥接可能依赖客户端内部接口或 Hook,具体实现和支持版本由 P0 验证确定;本规划没有认定任何私有发送入口已经可用。
|
||||
|
||||
| 范围 | 首版决定 |
|
||||
| --- | --- |
|
||||
| 平台 | Windows,本机企业微信已登录 |
|
||||
| 账号 | 每个运行实例只托管一个明确选定的在线员工账号 |
|
||||
| 会话 | 已有客户的一对一会话,按账号与会话 ID 选择托管范围 |
|
||||
| 内容 | 文本接收与文本回复;其他内容进入人工处理 |
|
||||
| 回复方式 | 自动发送、审核后发送 |
|
||||
| 接收来源 | 真实本地消息数据库;客户端消息事件安排在后续版本 |
|
||||
| 发送方式 | 客户端桥接;现有视觉通道可单独选择 |
|
||||
| AI 与业务 | 复用当前模型、知识、工具、内容审核和业务处理规则 |
|
||||
| 后续扩展 | 图片/文件、客户端事件、多账号并发、满足条件的自动切换 |
|
||||
| 不属于首版 | 群聊、批量营销、添加客户、脱离客户端的独立登录与完整网络协议 |
|
||||
|
||||
官方应用消息、微信客服和客户群发不能直接代替本需求中的原员工即时单聊。完整网络私有协议另行评估;数据库解密、写入本地消息表均不能证明消息已发送。
|
||||
|
||||
## 2. 现有基础与改造点
|
||||
|
||||
| 现有组件 | 已核对的能力 | 规划改造 |
|
||||
| --- | --- | --- |
|
||||
| wxwork_db.py、reply_database.py | 增量读取;返回 account、conv_id、server_id、rowid;上下文仍按名称匹配 | 增加按准确账号/会话 ID 查询、持久化游标与数据库重建处理;保留视觉名称查询路径 |
|
||||
| engine_b.py | 数据库/档案检测后入队;依赖昵称指纹且存在同名过滤 | 协议路径直接保留真实身份;数据库不可用时不得退化成依赖视觉回写的档案监听 |
|
||||
| wechat_bot.py | 入队、合并、AI、审核、视觉发送和结果核对 | 抽取业务调度与发送接口,视觉检查留在视觉发送器内部 |
|
||||
| gui_runtime.py | BotThread 启动时连接窗口,再运行 A/B 引擎 | 增加独立协议运行路径,启动、消费队列和恢复均不要求截图、激活或切换聊天窗口 |
|
||||
| send_lock.py | 本机跨进程窗口发送互斥 | 保留视觉资源锁,新增覆盖两个通道的统一回复事务占用 |
|
||||
| conversation_store.py、queue_log.py | 档案、有限历史范围内的 exchange_id 排重、队列事件 | 复用展示格式;新增持久发送账本作为长期排重依据 |
|
||||
| wechat_gui_qt.py、assets/ui/console-app.js、assets/ui/zhenyang-ai-console.html | 控制台、队列、审核、消息库、设置和日志 | 扩展现有页面,通过真实 Qt 数据和动作接口驱动 |
|
||||
|
||||
## 3. 功能清单
|
||||
|
||||
P0 为可行性验证;P1 为首个可用版本的必备功能;P2 为后续扩展。
|
||||
|
||||
| 编号 | 功能 | 行为 | 阶段 |
|
||||
| --- | --- | --- | --- |
|
||||
| F01 | 运行通道选择 | 视觉/协议互斥,与自动发送/审核后发送分别配置 | P1 |
|
||||
| F02 | 账号与客户端检测 | 展示企业、员工、在线状态、客户端版本及适配结果 | P0/P1 |
|
||||
| F03 | 桥接连接管理 | 检测、重连、停止;恢复后重新核对账号和会话映射 | P0/P1 |
|
||||
| F04 | 托管会话管理 | 从真实会话选择客户,支持同名客户和逐会话暂停 | P1 |
|
||||
| F05 | 新消息监听 | 按账号、数据库代次持久化游标;消息落盘后才确认消费 | P1 |
|
||||
| F06 | 连续消息合并 | 复用当前等待时间;补充消息更新版本,淘汰过期回复 | P1 |
|
||||
| F07 | AI 回复生成 | 复用模型、知识、工具、审核;失败可见,取消后丢弃未发送结果 | P1 |
|
||||
| F08 | 协议文本发送 | 固定原账号和原会话;长度按验证能力校验,不静默截断或拆成多条 | P0/P1 |
|
||||
| F09 | 审核后发送 | 草稿保存在软件内,可查看、编辑、批准和转人工 | P1 |
|
||||
| F10 | 人工接管 | 持久化会话托管权;识别人工已发消息,停止后续自动发送 | P1 |
|
||||
| F11 | 发送结果核对 | 区分提交、受理、确认和结果不明;按可关联证据确认发送 | P0/P1 |
|
||||
| F12 | 暂停与恢复 | 停止新处理和新发送,已提交请求继续只读核对;恢复前核对身份 | P1 |
|
||||
| F13 | 失败与重试 | 仅明确未提交/未送出的可重试错误允许重试,结果不明禁止重发 | P1 |
|
||||
| F14 | 任务、档案和日志 | 展示来源、通道、身份、耗时、错误与核对证据,可导出脱敏诊断 | P1 |
|
||||
| F15 | 版本兼容 | 未适配版本不可用;升级、账号变化后重新检测 | P0/P1 |
|
||||
| F16 | 模式切换 | 保存事务,交接未提交任务,保留原通道未决请求的核对责任 | P1 |
|
||||
| F17 | 客户端事件监听 | 事件接收与数据库补漏共用消息账本 | P2 |
|
||||
| F18 | 图片与文件 | 按消息类型验证上传、接收和发送结果,逐项开放 | P2 |
|
||||
| F19 | 多账号与自动切换 | 账号独立调度;满足未发送证据与身份映射条件才自动更换通道 | P2 |
|
||||
|
||||
## 4. 页面和交互
|
||||
|
||||
### 4.1 自动化设置
|
||||
|
||||
新增“运行通道”区域,保留现有“发送方式”区域。
|
||||
|
||||
| 设置 | 首版行为 |
|
||||
| --- | --- |
|
||||
| 运行通道 | 视觉模式 / 协议模式;已有用户升级后保持视觉模式 |
|
||||
| 发送方式 | 沿用 send_mode=auto/review,切换通道不改变审核方式 |
|
||||
| 托管账号 | 显示真实在线身份,不按员工姓名或历史数据目录自动选择 |
|
||||
| 托管会话 | 搜索并选择已有客户,可排除或暂停;默认空范围,不接管全部历史客户 |
|
||||
| 接收来源 | 协议首版固定“数据库增量读取”,不可用时明确暂停 |
|
||||
| 合并和发送前等待 | 复用现有配置及限制,显示实际运行值 |
|
||||
| 通道检测 | 只检测连接、身份、数据库和发送能力,不向客户发送测试消息 |
|
||||
| 测试发送 | 单独入口;选择测试会话、查看发送身份和完整文本后点击发送 |
|
||||
|
||||
适配未完成时显示“当前版本尚未支持”及原因,不能仅保存协议开关后继续执行视觉发送。窗口、DPI、鼠标空闲、输入框草稿等参数只在视觉模式下生效。
|
||||
|
||||
通道或账号切换执行 F16,不改变正在提交请求的目标或出口。普通参数在下一个处理批次生效。首版不展示尚未实现的“协议优先自动回退”。
|
||||
|
||||
### 4.2 自动化控制台
|
||||
|
||||
- 顶部同时展示运行通道、在线员工、发送方式与监听状态。
|
||||
- 协议模式把“企业微信窗口状态”替换为账号在线、客户端版本、桥接连接、数据库状态、最近收消息时间与待核对数量。
|
||||
- 执行链路显示“接收 → 合并 → AI → 审核/待发送 → 提交 → 核对”,不显示虚假的截图或回填步骤。
|
||||
- “已确认发送”只统计确认后的消息;已受理、待核对、明确失败分别展示。确认发送不等于客户已读。
|
||||
- 断开、版本不兼容、需要人工等状态发生变化时通知;重复轮询不重复刷屏。
|
||||
|
||||
### 4.3 任务队列与审核
|
||||
|
||||
列表增加来源、通道、员工和会话;技术 ID 放在详情中。
|
||||
|
||||
| 操作 | 行为 |
|
||||
| --- | --- |
|
||||
| 批准发送 | 批准当前所见草稿版本,发送前复核新消息、托管权和原账号;失效则重新生成/审核 |
|
||||
| 编辑回复 | 保存新草稿版本,使旧批准失效;编辑后可点击批准发送 |
|
||||
| 立即重试 | 只对明确可重试任务开放;已提交、已受理、结果不明禁止重试 |
|
||||
| 取消任务 | 撤销未提交回复;已经提交则停止后续动作并保留核对,不声称消息已撤回 |
|
||||
| 转人工 | 暂停会话自动发送,保留上下文并继续收录后续新消息 |
|
||||
| 恢复自动 | 用户主动恢复,读取最新上下文,从最新待处理客户轮次继续 |
|
||||
| 查看核对详情 | 展示请求 ID、提交时间、可用的消息 ID 与结果证据 |
|
||||
|
||||
协议审核草稿保存在软件内;现有视觉输入框草稿行为保持兼容,说明随通道变化。切换自动发送不能放行被业务规则强制审核的草稿。
|
||||
|
||||
### 4.4 消息库、档案与日志
|
||||
|
||||
- 消息库增加“托管此会话/暂停此会话”,使用真实账号与会话 ID。
|
||||
- 昵称变化不新建客户,同名客户不合并档案。
|
||||
- 客户消息正常归档;AI 草稿、受理中回复和确认发送分别标记,只有确认后才计为已发送。
|
||||
- 已确认但归档失败只补归档,不重发。
|
||||
- 日志按账号、会话、通道、任务和错误筛选。诊断导出默认不含密钥、登录凭证和完整聊天正文,复用已有脱敏能力。
|
||||
|
||||
## 5. 主流程与人工协作
|
||||
|
||||
1. 用户选择协议模式、在线账号和托管客户,启动监听。
|
||||
2. 验证客户端适配、数据库、实际在线身份与 ID 映射;不可用时显示具体原因。
|
||||
3. 接收新消息,在同一事务内写入消息账本及游标,再创建或更新会话任务。
|
||||
4. 等待配置的合并窗口,按 ID 读取上下文;我方消息、系统消息、非托管会话不触发回复。
|
||||
5. 调用现有 AI,记录生成所依据的消息版本;图片、文件或内容缺失进入人工处理,不根据占位文本猜测内容。
|
||||
6. 按审核方式和业务规则展示草稿或进入待发送。
|
||||
7. 最终复核任务、托管权、账号、消息版本、通道和限频。
|
||||
8. 占用统一发送事务,先落盘请求与状态,再向原会话提交。
|
||||
9. 收集返回和后续证据,进入确认、失败或待核对,更新档案与界面。
|
||||
10. 新客户轮次独立记录;已有未决发送的会话暂缓新发送,其他会话继续处理。
|
||||
|
||||
首次启用以当前消息位置建立基线,历史只作上下文,不自动回复所有历史。重启按已确认消费游标恢复;过期且未发送的积压任务交人工检查,已提交的请求始终保留核对记录。
|
||||
|
||||
人工接管为持久会话状态。我方消息先与自动发送账本匹配;无法匹配的按人工行为处理,避免自身回写触发循环。匹配不能只比较文本。
|
||||
|
||||
首版不能承诺感知其他设备上的“正在输入”。操作人员接手前可点击“转人工”;检测到人工已发送消息则取消未提交草稿。若人工发送与协议提交同时发生,记录竞争并停止后续自动发送,不承诺已提交请求可撤销。
|
||||
|
||||
## 6. 发送状态与异常处理
|
||||
|
||||
| 状态 | 含义 | 可执行动作 |
|
||||
| --- | --- | --- |
|
||||
| queued / collecting / generating | 等待、合并、生成 | 取消、转人工,后续消息可更新版本 |
|
||||
| review | 等待审核 | 编辑、批准、取消、转人工 |
|
||||
| ready | 已通过业务检查,尚未提交 | 提交前复核,可取消或更换通道 |
|
||||
| submitted | 已持久化发送意图,调用可能已进入客户端 | 查询或等待,不直接重发 |
|
||||
| accepted | 客户端受理,最终发送未确认 | 等待证据,不算客户收到 |
|
||||
| uncertain | 超时、中断或证据不足 | 只读核对,冻结本会话后续发送,不阻塞其他会话 |
|
||||
| confirmed | 达到 P0 定义的发送确认标准 | 幂等归档,不表示客户已读 |
|
||||
| retry_wait | 明确未发出的可恢复失败 | 退避重试或立即重试 |
|
||||
| failed / manual / cancelled / expired | 失败、人工、取消或未发送任务过期 | 记录原因,按具体情况人工处理 |
|
||||
|
||||
确认标准由 P0 实测:优先使用可关联请求的服务端结果,或具有明确发送成功语义的客户端状态。数据库出现我方消息、方法返回 true、同样文本出现,均不能单独证明成功;接收端实测用于检验这些证据是否可靠。
|
||||
|
||||
只有明确未进入发送入口且不会晚到执行的失败,才能判为未提交。IPC 断开、超时、租约过期都不能自行推断未发送。若桥接不支持可证明的幂等,保留不确定状态,不承诺端到端严格一次投递。
|
||||
|
||||
| 异常 | 处理 |
|
||||
| --- | --- |
|
||||
| 未登录、登录失效、客户端验证 | 暂停该账号发送,提示处理客户端,恢复后重验身份 |
|
||||
| 数据库断开或解析失败 | 暂停生成和新发送,保留游标,恢复后补齐 |
|
||||
| 桥接或客户端退出 | 停止提交,有限退避重连,未决请求继续保留 |
|
||||
| 客户端升级不兼容 | 协议不可用,用户可选择视觉通道并执行事务交接 |
|
||||
| AI 超时 | 保留消息版本,按现有重试和人工规则处理 |
|
||||
| 目标映射缺失或冲突 | 转人工,不按昵称猜目标 |
|
||||
| 明确拒绝发送 | 分类重试、暂停账号或转人工,不统一无限重试 |
|
||||
| 长时间无法核对 | 显示待人工核对,可关闭此轮并保留不明账本,不提供自动重发 |
|
||||
| 停止、取消或切换通道 | 未提交任务停止,已提交请求继续核对 |
|
||||
|
||||
## 7. 技术接口与数据
|
||||
|
||||
### 7.1 分层
|
||||
|
||||
~~~mermaid
|
||||
flowchart TD
|
||||
A[数据库增量读取] --> C[消息账本与回复调度]
|
||||
B[客户端事件:后续扩展] --> C
|
||||
C --> D[上下文、AI、审核、人工接管]
|
||||
D --> E[统一发送事务]
|
||||
E --> F[协议发送器]
|
||||
E --> G[视觉发送器]
|
||||
F --> H[本机桥接与已登录客户端]
|
||||
H --> I[结果核对]
|
||||
G --> I
|
||||
I --> J[账本、档案与控制台]
|
||||
~~~
|
||||
|
||||
以下为拟新增模块,不代表已有实现。现有 model_protocol.py 属于模型功能,不复用它承载企业微信通信。
|
||||
|
||||
| 拟新增模块 | 职责 |
|
||||
| --- | --- |
|
||||
| reply_types.py | 账号/会话/消息身份、草稿版本、发送请求与结果 |
|
||||
| reply_service.py | 无窗口依赖的合并、上下文、AI、审核、取消 |
|
||||
| reply_runtime_store.py | 消息、游标、任务、发送账本、托管权与身份别名 |
|
||||
| reply_dispatcher.py | 通道选择、事务占用、版本核对、失败分类和结果核对 |
|
||||
| reply_transports.py | 统一发送接口和视觉兼容适配 |
|
||||
| wxwork_protocol_client.py | 本机桥接通信、能力检测、账号绑定、结果查询 |
|
||||
| wxwork_protocol_runtime.py | 协议启动、停止、重连、健康状态 |
|
||||
| wxwork_protocol_bridge/ | 客户端版本相关的原生桥接,语言与交付物由 P0 确定 |
|
||||
|
||||
桥接最小接口:health、capabilities、get_account、resolve_conversation、send_text、query_send_result。查询不足明确返回“不支持/未知”,不能伪造确认;消息订阅为 P2。
|
||||
|
||||
本机通信首选限制当前用户访问的 Windows 命名管道,请求携带协议版本、请求 ID 与运行会话标识。登录态留在客户端;服务重启不重放发送请求。独立服务便于恢复,但若需要在客户端内执行代码,仍需在 P0 验证客户端崩溃风险。
|
||||
|
||||
### 7.2 统一身份和请求
|
||||
|
||||
| 数据 | 必须信息 |
|
||||
| --- | --- |
|
||||
| AccountIdentity | 验证后的账号键、企业范围(可用时)、员工 ID、进程与登录会话代次 |
|
||||
| ConversationIdentity | 账号键、真实会话 ID、会话类型、显示名;数据库/桥接 ID 显式映射 |
|
||||
| IncomingMessage | 账号、会话、来源、消息 ID、发送者、方向、时间、类型和内容 |
|
||||
| ReplyTask | task_id、会话键、客户消息集合/水位、生成版本、草稿版本、审核、托管状态、通道 |
|
||||
| SendAttempt | attempt_id、task_id、账号/会话、通道、内容摘要、幂等键、提交时间、状态与证据 |
|
||||
|
||||
数据目录名不直接视为在线身份。跨企业相同员工 ID、同名客户和切换账号必须区分;先暂停旧账号任务,再切换账号。
|
||||
|
||||
优先以账号范围内服务端消息 ID 去重;缺失时使用来源数据库代次、会话和本地行 ID。数据库重建后 rowid 重用不能与旧消息混淆。视觉来源无法对应稳定消息 ID 时只补充已映射任务;映射不唯一的会话禁止两个通道同时消费。
|
||||
|
||||
批准记录绑定 task_id、input_revision、draft_revision、content_hash。模型、审核期间或提交前消息变化,使旧回复和旧批准失效。请求重查保持 attempt_id,不能超时后换一个 ID 重新提交。
|
||||
|
||||
### 7.3 持久化、兼容与回退
|
||||
|
||||
拟在 application_data_dir() 下新增 reply_runtime.sqlite,事务维护消息、游标、任务、发送尝试、会话托管权和身份映射;不存客户端凭证。索引和保留周期按数据量确定,首版不自动清除未决发送账本。
|
||||
|
||||
- 消息与游标同事务提交;读取/入库失败不确认消费。改造读取器 checkpoint/ack,避免内存游标先前进后丢消息。
|
||||
- 发送尝试先落盘再触发客户端;重启后 submitted/accepted/uncertain 先核对。
|
||||
- 每会话只允许一个待提交或未决回复;不同会话独立收消息和生成,首版同账号发送按序执行并沿用限频。
|
||||
- 协议与视觉共用发送账本;视觉窗口另有资源锁。占用过期只触发恢复检查,不赋予重新发送资格。
|
||||
- pending_replies.json 一次性迁移后作为兼容展示快照,不再与 SQLite 双向独立写任务;Qt、经典界面及离线任务操作都改用统一存储接口。
|
||||
- 迁移在监听停止时进行,保留原文件、未知字段和迁移标识;无法准确映射的任务留在视觉/人工路径,不按昵称强行合并。
|
||||
- 档案通过身份别名兼容;旧档案对应多个真实会话时不自动复制或混入任一客户上下文。
|
||||
- 首版回退指新版内切回视觉。降级旧 EXE 前需处理未决请求并生成无歧义快照,不能让旧 EXE 用过期 JSON 恢复发送。
|
||||
|
||||
### 7.4 配置
|
||||
|
||||
| 字段 | 首版行为 |
|
||||
| --- | --- |
|
||||
| reply_transport | visual / protocol,默认 visual |
|
||||
| send_mode | 沿用 auto / review |
|
||||
| protocol_account_key | 默认空,要求实际身份绑定 |
|
||||
| protocol_managed_conversations | 托管会话集合,默认空 |
|
||||
| protocol_receive_source | 固定 database |
|
||||
| protocol_auto_fallback | 首版固定 false,界面不开放 |
|
||||
| 合并、等待、限频 | 复用当前配置,新增参数单独校验 |
|
||||
|
||||
配置覆盖 Qt 默认值/加载/保存、经典界面和 BotThread 传参;账号或来源未就绪时不能静默沿用其他账号/通道。
|
||||
|
||||
## 8. 分阶段开发顺序
|
||||
|
||||
| 阶段 | 工作项 | 交付和完成条件 |
|
||||
| --- | --- | --- |
|
||||
| P0 能力验证 | 实际版本/架构、在线身份、ID 映射、文本发送、确认依据、断线重登 | 可复现实验报告、支持矩阵、最小桥接原型;原账号原会话实测成立,结果可判别 |
|
||||
| P1-A 基础服务 | 数据类型、统一存储、ID 上下文、确认游标、身份/历史迁移、发送事务 | 共用消息与发送账本;故障模拟无重复消费/发送,视觉回归通过 |
|
||||
| P1-B 收发闭环 | 桥接发送器、协议运行时、合并与 AI、核对、暂停恢复、人工接管 | 单账号文本闭环真实通过,不要求窗口激活、截图或键鼠输入 |
|
||||
| P1-C 产品入口 | 通道设置、真实状态、托管客户、审核编辑、队列动作、日志、打包 | 界面可配置、审核、接管、定位失败和切回视觉;源码与 EXE 行为一致 |
|
||||
| P1-D 稳定验证 | 故障注入、重启、竞争场景、连续运行、安装升级 | 必测验收通过,发布说明明确支持版本与运行条件 |
|
||||
| P2 扩展 | 客户端事件、媒体、多账号、受约束自动切换 | 按能力单独验证,不作为首版前提 |
|
||||
|
||||
P0 建议以 2—3 个工作日作为探索预算,不是成功承诺。无法取得可靠发送或确认能力时,交付验证结论与缺口,协议功能保持不可用。P1 工期在 P0 后根据桥接复杂度、确认能力和兼容范围估算。
|
||||
|
||||
## 9. 验收矩阵
|
||||
|
||||
以下验收尚未执行。模拟适配器用于故障测试,不能替代真实企业微信发送验证。
|
||||
|
||||
| 编号 | 场景 | 通过标准 | 功能 |
|
||||
| --- | --- | --- | --- |
|
||||
| A01 | 原员工回复现有测试客户 | 接收端收到,身份、原会话正确,记录版本与证据 | F02/F08/F11 |
|
||||
| A02 | 遮挡、最小化、鼠标操作其他软件 | 支持环境中不调用截图、激活、剪贴板、键鼠;锁屏独立验证后才列为支持 | F01/F08 |
|
||||
| A03 | 同名客户、昵称改变 | 上下文、草稿、发送、档案对应正确会话 | F04/F05/F08 |
|
||||
| A04 | 同秒多条、迟到入库、跨页、数据库重建 | 无漏读,崩溃后按确认游标恢复,无旧消息误判 | F05 |
|
||||
| A05 | 重启、重复检测、相同内容多次发送 | 同消息 ID 只处理一次,不同 ID 不因文本相同被丢弃 | F05/F11 |
|
||||
| A06 | 首次启用、旧队列迁移 | 不回复全部历史,未决任务不降级为可重试,同名历史不串档 | F04/F05/F16 |
|
||||
| A07 | 合并、生成、审核时客户补充消息 | 更新轮次,过期草稿与批准失效,旧回复不提交 | F06/F09 |
|
||||
| A08 | 编辑后批准、重复点击批准 | 发送最新获批版本,不产生第二次发送 | F09/F11 |
|
||||
| A09 | 人工接管、手机回复、机器人自身回写 | 停止后续自动发送,自身回写不触发循环 | F10 |
|
||||
| A10 | 提交后超时、桥接崩溃/重启 | 不重发,保留不明状态,其他会话继续 | F11/F12/F13 |
|
||||
| A11 | 返回成功但接收端未收到 | 不把受理计为确认,显示核对/失败 | F11/F14 |
|
||||
| A12 | 已发送但归档失败、历史裁剪 | 只补归档,长期账本继续阻止重发 | F11/F14 |
|
||||
| A13 | 发送中暂停、取消、转人工、切换通道 | 不声称已提交请求撤销,后续发送停止,无跨通道重复提交 | F10/F12/F16 |
|
||||
| A14 | 断网、掉线、换账号、重新登录 | 错误可见,重验身份,旧账号任务不发到新账号 | F02/F03/F12 |
|
||||
| A15 | 升级、未适配版本 | 协议不可用,可明确选择视觉并完成事务交接 | F15/F16 |
|
||||
| A16 | 图片/文件、超限文本、映射缺失 | 明确交人工,不按占位内容回答 | F07/F08/F13 |
|
||||
| A17 | 桥接/读取卡住、连续 UI 操作 | UI 可操作,超时可见,无重复监听进程与占用泄漏 | F03/F12/F14 |
|
||||
| A18 | EXE 安装、重启、升级、切回视觉 | 数据持久保存,通道和审核方式保持,迁移可核查 | F01/F15/F16 |
|
||||
|
||||
稳定性目标:单账号、至少 10 个独立测试会话、200 个受控消息轮次和连续 24 小时运行,包含断线、进程重启及人工竞争。错账号、错会话、可归因于系统的重复发送为零;每轮有可追踪结果。分别记录检测、合并等待、AI、提交和确认耗时,实测后制定性能指标,不把配置等待算作性能故障。
|
||||
|
||||
实施时按改动选取已有回归:test_dual_engine.py、test_database_reply_priority.py、test_self_reply_loop.py、test_send_lock.py、test_runtime_settings.py、test_qt_console_bridge.py,以及相关队列、档案和打包测试。新增测试聚焦身份冲突、提交边界崩溃、游标提交、恢复及跨通道竞争。
|
||||
|
||||
## 10. 实施清单与下一步
|
||||
|
||||
- [ ] P0:验证实际客户端、桥接收发及真实确认依据。
|
||||
- [ ] P1-A:统一身份、消息账本、游标、发送事务和迁移。
|
||||
- [ ] P1-B:实现数据库接收、AI、协议文本发送、核对和人工接管。
|
||||
- [ ] P1-C:接入设置、控制台、队列、审核、档案、诊断、打包。
|
||||
- [ ] P1-D:完成验收矩阵、稳定运行、升级与视觉回归。
|
||||
- [ ] P2:逐项扩展事件、媒体、多账号和自动切换。
|
||||
|
||||
下一项工作为 P0。本文和任务索引不代表协议发送功能已经完成。
|
||||
|
||||
## 11. 已核对的官方能力参考
|
||||
|
||||
这些链接用于界定官方接口范围,不是私有桥接接口规范,内容已在本次方案讨论中核对。
|
||||
|
||||
- [发送应用消息](https://developer.work.weixin.qq.com/document/path/90236):应用向可见范围内成员发消息。
|
||||
- [微信客服发送消息](https://developer.work.weixin.qq.com/document/path/94677):客服账号和客服会话的消息发送。
|
||||
- [创建企业群发](https://developer.work.weixin.qq.com/document/path/92135):创建任务并由成员确认发送。
|
||||
- [获取会话内容存档](https://developer.work.weixin.qq.com/document/path/91774):获取会话记录,不提供原员工即时单聊发送能力。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 2026-09-15 安装包与安全复核
|
||||
|
||||
## 已确认的事实
|
||||
|
||||
- 1.4.11 主程序在 2026-09-15 10:22:47(UTC+08:00)被 Microsoft Defender 检测,10:23:04 隔离成功。
|
||||
- 告警:`Behavior:Win32/DefenseEvasion.A!ml`;Threat ID `2147738096`;严重度 5。
|
||||
- 当时引擎 `1.1.26080.3`,检测库 `1.459.211.0`。
|
||||
- 原受检主程序 SHA256:`36dfa102dba15aba618c246ce613a03fce6b0fbdce2fd3c2f1ed11a456b6878a`。
|
||||
- 原程序已被隔离,未恢复隔离文件。没有关闭防护、添加排除项或修改 Defender 设置。
|
||||
- 这不是仅凭“未知发布者”推断的告警,已有本机 Defender 检测记录。
|
||||
|
||||
## 实现与判断边界
|
||||
|
||||
原账号协议发送会对版本和 SHA256 固定的 WXWork.exe 创建临时内存,写入发送调用代码,
|
||||
再通过远程线程调用进程内 MessageService。这属于进程注入式调用,包含
|
||||
`VirtualAllocEx`、`WriteProcessMemory`、`CreateRemoteThread`。
|
||||
这些行为与检测类别相符,但现有 Defender 日志没有说明准确触发的 API 或规则。
|
||||
不能将告警直接定性为误报;用户授权发送也不等于杀毒厂商已认可这一实现。
|
||||
|
||||
用户要求保留视觉和协议两种功能,因此没有删除、混淆或更换协议发送路径。
|
||||
受支持的原账号接口目前仍不可用:先前内置 SDK 连接成功,但业务查询返回用户未开通灰度权限。
|
||||
此前 1.4.11 的实发与回执验证仍是功能证据,不构成安全验收。
|
||||
|
||||
## 本次修复
|
||||
|
||||
1. 从本项目既有 1.4.10 包中恢复四个归档源码至 `archive_exporter/`,记录 SHA256。
|
||||
2. spec 默认读取本项目源码,并在 PyInstaller 前检查完整性及语法。
|
||||
3. 源码运行优先发现本项目导出器;保持安装版的数据目录隔离。
|
||||
4. 修正把杀毒告警归因于无签名的说明,并修复签名脚本对缺失产物的漏检。
|
||||
5. 修复版本为 1.4.12。9 个协议/界面关键文件与已验证 1.4.11 源码哈希相同。
|
||||
6. 归档/打包测试 15 项通过,协议及运行配置回归 149 项通过;这些测试使用模拟数据。
|
||||
|
||||
## 待完成的安全验收
|
||||
|
||||
完整构建及打包自检已通过。安装包 352,065,632 字节(约 336 MiB)。
|
||||
Defender 官方更新后检测库为 1.459.213.0;对整个应用目录及安装包的两次扫描均报告 found no threats,退出码均为 0。
|
||||
归档补充回归 29 项通过,连同上述测试共 193 项。
|
||||
未重新执行真实协议发送,所以不能声称运行时行为告警已经解决。
|
||||
静态扫描通过只表示当次文件扫描未检出,不能替代协议发送行为复核。
|
||||
本机没有可用的代码签名证书;不能声称产物已具有可信发布者身份。
|
||||
|
||||
## 可提交微软的脱敏说明草稿
|
||||
|
||||
Subject: Request for analysis of Behavior:Win32/DefenseEvasion.A!ml in a WeCom desktop automation application
|
||||
|
||||
Our locally developed Windows customer-service application was detected by Microsoft Defender
|
||||
on 2026-09-15 at 10:22:47 UTC+08:00. The detected product version was 1.4.11.
|
||||
Threat ID: 2147738096. Engine: 1.1.26080.3. Intelligence: 1.459.211.0.
|
||||
Detected executable SHA256: 36dfa102dba15aba618c246ce613a03fce6b0fbdce2fd3c2f1ed11a456b6878a.
|
||||
The original executable was quarantined and has not been restored.
|
||||
|
||||
The application provides visual automation and an experimental original-account text transport.
|
||||
The latter validates a specific installed WXWork.exe version/hash, allocates memory in that process,
|
||||
writes a temporary call stub, and creates a remote thread to invoke its existing MessageService.
|
||||
This functionality was used for an explicitly authorized test conversation. We are describing
|
||||
these operations transparently and request assessment of the detection; we do not assume it is a false positive.
|
||||
|
||||
Version 1.4.12 fixes missing archive-exporter build inputs. The protocol implementation is unchanged.
|
||||
A rebuilt sample is different from the originally detected executable and should be assessed as such.
|
||||
No security exclusions, protection disabling, obfuscation, or injection-evasion changes were applied.
|
||||
Please provide a classification and any relevant remediation guidance for legitimate distribution.
|
||||
|
||||
No customer names, account identifiers, chat records, credentials, memory dumps, or private keys
|
||||
are included in this text. Any binary submission requires a separate review of the included code/data.
|
||||
|
||||
## 官方复核依据
|
||||
|
||||
- Microsoft 调查与处理检测行为:https://learn.microsoft.com/en-us/defender-endpoint/address-unwanted-behaviors-mde
|
||||
- Microsoft 文件分析提交入口:https://www.microsoft.com/en-us/wdsi/filesubmission
|
||||
- PyInstaller 项目关于误报复核的说明:https://github.com/pyinstaller/pyinstaller/issues/6062
|
||||
|
||||
## 最终状态与产物
|
||||
|
||||
- 安装包:`dist/installer/甄AI客服-安装包-v1.4.12.exe`
|
||||
- 安装包 SHA256:`68fc20558af2dff47769a75c446030d1cd813375bb2724ce6b6f6f5e69883d66`
|
||||
- 主程序 SHA256:`b3ab5e65d27785bd45f91763e38b3c66e9f18ce501e7fee043033e67374ab3fe`
|
||||
- 扫描报告:`tmp/installer-v1.4.12-security.json`
|
||||
- 验证汇总:`release_acceptance_v1.4.12.json`
|
||||
- 用户已明确同意向微软提交主程序和脱敏说明,不需要再次索取同一授权。
|
||||
- 实际提交状态:尚未提交。浏览器工具重试和电脑操作工具均在启动时返回 `windows sandbox failed: helper_unknown_error: apply deny-read ACLs`,没有执行上传,也没有收到提交编号。
|
||||
|
||||
手动提交入口:https://www.microsoft.com/en-us/wdsi/filesubmission
|
||||
选择 Software developer、Microsoft Defender Antivirus (Windows 11)。
|
||||
仅选择 `dist/ZhenAI-WeCom-Assistant-v1.4.12/ZhenAI-WeCom-Assistant-v1.4.12.exe`,约 15 MB;不要上传 336 MB 安装包或项目目录。
|
||||
检测名称为 Behavior:Win32/DefenseEvasion.A!ml;历史检测库填写 1.459.211.0。
|
||||
Additional information 可粘贴 `tmp/microsoft-defender-review-v1.4.12.txt`(不超过页面的 1900 字符限制)。
|
||||
若页面出现登录或验证码,由用户完成。没有收到提交编号前,不应标记为“已提交”或“微软已通过”。
|
||||
@@ -0,0 +1,146 @@
|
||||
# 甄养堂企业微信 AI 自动回复 v2.0 — UI 与交互规格
|
||||
|
||||
## 1. 全局规范
|
||||
|
||||
- 参考画布:窗口内容区固定基准为 `1630 × 920`;Windows 原生标题栏约 `45px`,完整窗口对应 `1630 × 965`。小屏幕只做等比可用性收缩,不改变模块顺序。
|
||||
- 字体:页面标题 `HarmonyOS Sans SC Bold / 700`;菜单和模块标题 `Medium / 500–600`;正文 `Regular / 400`;数字指标 `Inter Semibold / 600`;Windows 回退 `Microsoft YaHei UI`。
|
||||
- 字体文件随程序打包注册;没有安装系统字体时仍使用同一字体资源。
|
||||
- 左侧导航:点击切换页面;选中态为蓝紫发光圆形图标和浅蓝背景;hover 为浅蓝底;底部监控球在停止时启动监听,在运行时停止监听。
|
||||
- 数据原则:设计稿里的数值和会话文字是构图示例;运行时显示真实企业微信、队列、档案、AI 与日志数据,不以假数据覆盖业务状态。
|
||||
- 页面内容区在基准窗口内不显示滚动条;会话正文、任务列表等局部数据区域按需内部滚动。
|
||||
|
||||
## 2. 桌面自动化控制台
|
||||
|
||||
### 顶部状态与任务概览
|
||||
|
||||
- 企业微信、AI 服务、发送模式和监听按钮:均展示真实运行状态;`自动发送` 启动监听,`暂停监听 / 恢复监听` 安全停止或恢复同一后台线程。
|
||||
- `监听状态 / 排队 / 今日回复 / 失败`:从当前线程、待回复队列和执行日志动态汇总;只读展示,不允许直接改数。
|
||||
- 任务队列:加载真实待处理会话;筛选和排序图标按既定顺序循环,不改变参考稿的图标、按钮数量或占位尺寸;刷新按钮立即重读队列与档案。
|
||||
|
||||
### 当前任务、执行链路与事件
|
||||
|
||||
- 当前处理任务:只读显示客户、已读取消息数、来源消息、回复预览、置信度和模型耗时;没有任务时保持同一卡片区域并显示空状态。
|
||||
- `终止本任务`:二次确认后取消对应未完成回复;`转人工`:二次确认后停止自动发送并保留档案。
|
||||
- 自动化执行状态:按真实阶段展示扫描、读取、合并、AI、回填和发送进度;企业微信窗口状态展示窗口识别、当前会话、DPI、人工保护和最小化恢复状态。
|
||||
- 实时事件:显示最近四条真实事件;`查看完整日志` 跳转运行日志页。
|
||||
|
||||
## 3. 任务队列
|
||||
|
||||
### 顶部操作
|
||||
|
||||
- `自动发送`:点击后启动真实监听线程;线程已运行时给出运行中日志,不重复创建线程。
|
||||
- `暂停监听`:点击后安全停止监听;未完成任务保留,恢复监听后继续处理。
|
||||
- 企业微信、AI 状态胶囊:动态状态展示,不可编辑。
|
||||
|
||||
### 状态带与任务列表
|
||||
|
||||
- `处理中 / 等待中 / 待重试 / 平均等待`:每 2 秒从真实待回复文件更新。
|
||||
- `全部 / 处理中 / 等待中 / 待重试`:单选筛选;只隐藏不匹配行,不删除任务。
|
||||
- 任务列表:动态加载、内部滚动、不分页;选择任务后右侧详情同步更新。
|
||||
- 刷新按钮:立即重读队列和执行记录。
|
||||
- 空队列:正常运行显示“当前没有待处理任务”,不会伪造设计稿任务;只有 `--qt-smoke-test` 截图模式在真实队列不足时注入五条隔离的参考构图数据,演示项不能执行删除、转人工或导出。
|
||||
|
||||
### 任务详情
|
||||
|
||||
- 会话 ID、等待时间、重试次数、来源消息、当前阶段:只读动态数据。
|
||||
- `取消任务`:要求二次确认;取消未完成自动回复,保留会话档案和执行记录。
|
||||
- `立即重试`:只对所选 `retry_wait` 任务清除退避并重新排队;发送中、回执不确定、等待人工审核和正常任务受保护。监听未运行时会先启动监听,并在机器人初始化后执行重试。
|
||||
- `转人工`:要求二次确认;取消该任务的后续自动发送并记录转人工日志,会话档案保留。
|
||||
- `查看完整日志`:跳转运行日志页。
|
||||
|
||||
## 4. 会话归档
|
||||
|
||||
### 顶部筛选
|
||||
|
||||
- 搜索框:占位文字“搜索客户或会话 ID”,最大 80 字;按客户名、会话 ID 和摘要即时过滤。
|
||||
- `全部会话 / 今日 / AI 已回复 / 已转人工 / 日期`:互斥筛选;筛选仅影响左侧可见列表。
|
||||
- `导出记录`:打开保存对话框,导出全部真实会话及消息为 UTF-8 JSON;取消对话框不写文件。
|
||||
|
||||
### 数据区域
|
||||
|
||||
- 最近会话:最多加载 500 份档案,列表内部滚动,不分页;头像、名称、摘要、时间和状态来自档案数据。
|
||||
- 会话记录:选择左侧会话后加载最近 8 条用于页面展示;区域内部滚动;完整记录仍可从详情和导出读取。
|
||||
- 客户记忆、业务沉淀:展示长期上下文、挂号登记、回访和人工跟进状态。
|
||||
- `复制全部记录`:打开所选会话的完整记录窗口,可复制完整文本。
|
||||
- `标记已联系`:对选中的真实登记记录更新状态;没有选择时提示。
|
||||
- `删除所选`:二次确认后删除所选会话档案;不可撤销。
|
||||
|
||||
## 5. AI 设置
|
||||
|
||||
### 顶部与分页锚点
|
||||
|
||||
- `基础设置 / 知识与工具 / 安全策略`:互斥选中;点击滚动定位到对应模块,不重排页面。
|
||||
- `测试 AI`:先做配置预检,再异步调用真实 `ai_chat.call_ai_text`;只有收到非空响应才显示成功,模型未保存或请求失败会返回真实错误。
|
||||
- `保存并发布`:校验 MCP JSON 后保存客服名、主模型、上下文轮数、最大回复、温度、工具开关和工具轮数;成功或失败给出短时状态反馈。
|
||||
|
||||
### 表单限制
|
||||
|
||||
- 客服名称:可编辑,最大 40 字;空值回退云端客服名。
|
||||
- 人格提示词:明确标注为只读安全模板摘要,避免在 UI 层绕过现有 `build_system_prompt` 安全拼装逻辑。
|
||||
- 当前模型:点击可输入模型标识并持久化为 `AI_MODEL`;备用模型由云端策略管理,本地点击只显示说明,不伪装成已切换。
|
||||
- 上下文:`1–50` 轮;最大回复:`50–32000 tokens`;温度:`0.00–2.00`。
|
||||
- MCP 最大轮数:`1–20`;高级 JSON 根节点必须为数组,否则禁止保存并显示错误。
|
||||
- `立即同步`:执行真实云端配置同步;`管理知识`:打开本地数据目录。
|
||||
- 数据区为动态状态展示;知识库、工具和安全策略卡片不分页。
|
||||
|
||||
## 6. 自动化设置
|
||||
|
||||
### 模式与保存
|
||||
|
||||
- `自动发送`:AI 回复通过全部安全校验后自动按 Enter,并按真实聊天证据核对回执。
|
||||
- `审核后发送`:完成识别、AI 生成和全部发送前校验,只把回复填入企业微信输入框,绝不按 Enter;任务持久化为“等待人工审核”,人工发送后再按真实聊天证据完成归档。
|
||||
- 两个模式按钮互斥,写入 `send_mode`;运行中修改会热更新,下一条回复起生效,经典界面共享同一配置。
|
||||
- `保存设置`:立即保存;输入控件修改后 500ms 自动保存,成功或失败短时显示状态。
|
||||
|
||||
### 输入与开关
|
||||
|
||||
- 扫描间隔:`0.2–300.0 秒`。
|
||||
- 连续消息等待:使用运行时允许的消息合并最小值和最大值。
|
||||
- 发送前等待:独立参数 `0–30 秒`,发生在任何剪贴板/输入操作之前,可被停止或删除任务打断;等待结束重新执行任务与安全检查。
|
||||
- 人工操作保护恢复:独立参数 `0–3600 秒`,默认 `5 秒`,不与发送前等待共用。
|
||||
- 固定回复:最大 120 字;空值保存时使用安全默认回复。
|
||||
- 人工操作保护开关:开启后只在鼠标达到静止时间后自动操作。
|
||||
- `重新检测`:调用真实企业微信窗口检测并报告句柄或失败原因。
|
||||
- 诊断、用药调整、投诉退款是内置只读风险类别,使用禁用状态而非空点击;`规则管理` 跳转到“AI 设置 → 安全策略”。
|
||||
|
||||
## 7. 运行日志
|
||||
|
||||
### 顶部操作
|
||||
|
||||
- `实时刷新`:默认开启;点击暂停可见事件流更新,再次点击恢复。暂停不影响后台日志落盘。
|
||||
- `导出日志`:打开保存对话框,导出当前内存日志为 UTF-8 文本。
|
||||
- `清空日志`:清空内存日志、五行事件流和警告计数;不删除已经落盘的历史日志文件。
|
||||
- `监控中`:只读状态胶囊。
|
||||
|
||||
### 事件流
|
||||
|
||||
- `全部 / 连接 / 识别 / AI / 发送 / 警告`:互斥筛选。
|
||||
- 搜索框:最大 80 字,按标题、详情和状态即时过滤。
|
||||
- 页面保留最近 5 条结构化事件;完整内存日志最多 2000 个文本块,后台磁盘日志独立保留。
|
||||
- 系统健康、回复链路、异常追踪和最近活动均为动态或运行状态展示,不分页。
|
||||
|
||||
## 8. 系统设置
|
||||
|
||||
### 基础、数据和通知
|
||||
|
||||
- 开机启动、启动后监听、最小化到托盘、关闭窗口后台运行、四类通知:可切换;点击“保存设置”后写入本地设置并跨重启保留。
|
||||
- 最小化到托盘关闭时,运行中的窗口保持在任务栏;开启时进入监控胶囊。关闭窗口后台运行关闭时会正常停止并退出,开启时运行中关闭主窗进入监控胶囊。
|
||||
- 界面语言当前固定为简体中文;缩放比例选项保存为 Qt 启动偏好,默认“自动”,重启后生效。
|
||||
- 日志保留:`7–365 天`。
|
||||
- `打开目录`:打开应用数据目录。
|
||||
- `立即备份`:新建时间戳备份目录,复制存在的设置、档案、队列和登记数据。
|
||||
- `清理缓存`:二次确认后只删除视觉状态和误判缓存,不删除会话及队列。
|
||||
|
||||
### 维护与诊断
|
||||
|
||||
- `连接测试`:显示本地服务状态。
|
||||
- `检查更新`:读取缓存的发布状态并显示版本信息。
|
||||
- `导出诊断`:保存版本、设置、数据目录和文件清单为 JSON。
|
||||
- `恢复默认设置`:二次确认后恢复系统选项,不删除业务数据。
|
||||
- `查看许可信息`:显示软件版本及随包字体许可说明。
|
||||
- 系统状态带为只读结果展示,不分页。
|
||||
|
||||
## 9. 原有功能兼容层
|
||||
|
||||
- 原 PySide6 页面继续作为真实数据和业务动作的后端,不再作为可见页面重复占位;HTML 七页只负责按参考稿呈现。
|
||||
- 队列、档案、AI、自动化、日志和系统设置仍复用既有存储、线程、安全校验与导出逻辑,换肤不会绕过原有安全检查或丢失已有数据。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 高 DPI 与响应式界面修复(2026-09-16)
|
||||
|
||||
## 修复效果
|
||||
|
||||
- 「自动」跟随系统显示缩放,支持 100%、125%、200%、300% 等倍率。Qt 与内嵌网页各使用逻辑像素,不再把系统放大后的界面压回固定画布。
|
||||
- 字体、SVG 图标、按钮、边距保持统一比例。长页面滚动;空间不足时卡片减少列数,导航区独立滚动,监听和设置入口保留在侧栏底部。
|
||||
- 1080p、4K 和更高分辨率使用同一套逻辑布局。调整窗口高度和切换页面不会改变文字大小。
|
||||
- 初始窗口按当前显示器可用区域设定;移动到另一显示器或改变显示设置后,窗口重新约束在可用区域内。已处理负坐标副屏和显示器移除事件。
|
||||
- 修复原生 QBoxLayout、QGridLayout 的间距未跟随界面大小变化的问题。
|
||||
|
||||
## 使用
|
||||
|
||||
退出后从本目录重新运行 `python wechat_gui_qt.py`,进入「系统设置 → 界面大小」选择「自动」。以后直接调整 Windows 的显示缩放即可。
|
||||
|
||||
需要额外放大时可选择 100%–300%,相对于系统缩放立即生效,无需重启。恢复「自动」即可跟随系统。此设置不再写入进程级 `QT_SCALE_FACTOR`;开发者显式设置的环境变量仍由 Qt 正常处理。
|
||||
|
||||
这次更新的是协议目录源码及 UI 资源,已有 EXE/安装包未重新打包;旧 EXE 不会自动读取新源码。原监听、账号、聊天和协议配置没有被本次更新覆盖。
|
||||
|
||||
## 验证
|
||||
|
||||
- 138 项显示适配、Qt 控制台、悬浮胶囊、运行设置、模拟协议链路和打包路径测试通过。
|
||||
- 8 个页面 × 7 种显示条件,共 56 组浏览器检查:无横向溢出、无页面脚本异常,含 125% 分数倍率、2 倍/3 倍和小窗口。
|
||||
- 实际 Qt WebEngine 以 1、2、3 倍模拟 DPI 运行:960 逻辑像素宽的窗口分别输出 960、1920、2880 物理像素;Qt 与网页 DPR 一致,字体布局逻辑尺寸一致。
|
||||
- 窗口换屏、负坐标屏幕、缩小可用区域及移除显示器通过模拟事件回归;本机未进行实体三显示器拖拽验收。
|
||||
- 没有向真实客户发送测试消息。
|
||||
|
||||
## 文件与备份
|
||||
|
||||
主要文件:`wechat_gui_qt.py`、`assets/ui/console-app.js`、`assets/ui/console-responsive.css`、`assets/ui/zhenyang-ai-console.html`。
|
||||
|
||||
备份:`backups/dpi-adaptation-20260916`。哈希清单与测试结果:`UI_DPI_MANIFEST_20260916.json`。现有打包配置已包含整个 `assets/ui` 目录,新样式会随下次打包进入安装包。
|
||||
|
||||
技术依据:Qt 使用设备无关像素并按显示器 DPR 绘制,详见 [Qt High DPI](https://doc.qt.io/qt-6.8/highdpi.html)。页面采用参与布局计算的缩放及响应式栅格,详见 [CSS zoom](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/zoom)。
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"backup": "C:\\wechat_rpa\\backups\\dpi-adaptation-20260916",
|
||||
"manifest": [
|
||||
{
|
||||
"path": "test_qt_console_bridge.py",
|
||||
"before_sha256": "8be365cf7825de6d7a5abf81ba06b0a86419fe993db43c0d446b262caf3b9efc",
|
||||
"sha256": "3d95a455354bade92e45665adcd0ba4406602ffd04ff2c2c0286297dc00b4294"
|
||||
},
|
||||
{
|
||||
"path": "test_ui_display.py",
|
||||
"before_sha256": null,
|
||||
"sha256": "277dbdc588948f338d1ffcb3bfd90c3100e10ad93758aecd9233160f2125520e"
|
||||
},
|
||||
{
|
||||
"path": "wechat_gui_qt.py",
|
||||
"before_sha256": "650ac142135ab4c839af92b9f4ab80fbfde3caddef7e467c6e9f51ce932207c1",
|
||||
"sha256": "dfa731f6121d62f77bd64e8740d2eceba3587f002272539d47aadf425a210381"
|
||||
},
|
||||
{
|
||||
"path": "assets/ui/console-app.js",
|
||||
"before_sha256": "8d88e8f6a497a905bd7c93eeadbb1fe748b873f2a1f503fd9dd9532aabd87b50",
|
||||
"sha256": "7ba4350adc12f61bf88f35ea9b6a78f90a104028f4382424b73614b2a8a0f43d"
|
||||
},
|
||||
{
|
||||
"path": "assets/ui/console-responsive.css",
|
||||
"before_sha256": null,
|
||||
"sha256": "a8793d3422a096c0206d69ffc53b5925939b101fd2d0b2cc037e8681cc3bdc13"
|
||||
},
|
||||
{
|
||||
"path": "assets/ui/zhenyang-ai-console.html",
|
||||
"before_sha256": "13213e68920b4a329754e7ea83c74964e89433e314ac07c83137ce3313ec7dd1",
|
||||
"sha256": "66897064eedf26e6cae2ce15db9b250fd5d8305c68ea0037dbecdc97a40307e5"
|
||||
}
|
||||
],
|
||||
"tests_passed": 138,
|
||||
"browser_checks": 56,
|
||||
"browser_errors": [],
|
||||
"qt_dpi_checks": [
|
||||
{
|
||||
"ready": true,
|
||||
"dpr": 1,
|
||||
"zoom": 0.85,
|
||||
"titleWidth": 320.828125,
|
||||
"mainWidth": 942,
|
||||
"contentWidth": 942,
|
||||
"qtDpr": 1.0,
|
||||
"factor": "1",
|
||||
"imageWidth": 960,
|
||||
"logicalWidth": 960
|
||||
},
|
||||
{
|
||||
"ready": true,
|
||||
"dpr": 2,
|
||||
"zoom": 0.85,
|
||||
"titleWidth": 320.8203125,
|
||||
"mainWidth": 942,
|
||||
"contentWidth": 942,
|
||||
"qtDpr": 2.0,
|
||||
"factor": "2",
|
||||
"imageWidth": 1920,
|
||||
"logicalWidth": 960
|
||||
},
|
||||
{
|
||||
"ready": true,
|
||||
"dpr": 3,
|
||||
"zoom": 0.85,
|
||||
"titleWidth": 320.8177185058594,
|
||||
"mainWidth": 942,
|
||||
"contentWidth": 942,
|
||||
"qtDpr": 3.0,
|
||||
"factor": "3",
|
||||
"imageWidth": 2880,
|
||||
"logicalWidth": 960
|
||||
}
|
||||
],
|
||||
"installer_rebuilt": false,
|
||||
"artifacts": "C:\\kefu\\deploy\\dpi-adaptation-20260916",
|
||||
"guide": "UI_DPI_20260916.md"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# 切页和编辑流畅性修复(2026-09-16)
|
||||
|
||||
## 本次调整
|
||||
|
||||
- 状态快照只收集当前页面的数据。编辑 AI、自动化或系统设置时,不再遍历队列、归档、聊天和日志,也不读取其他页面配置。
|
||||
- 快速连续点击菜单先显示目标页面,再合并后台导航,只执行最后一次导航。等待新页面状态期间忽略上一页的迟到快照,避免页面回跳。
|
||||
- 取消隐藏旧界面的切换动画。列表中不可见的行延后绘制,图标替换从多轮字符串扫描改为单轮。
|
||||
- 日志/状态队列每轮最多处理 64 条,按 8 毫秒预算让出主线程,后续帧继续,保留事件顺序。单条操作不是强制中断,预算只在相邻条目之间检查。
|
||||
- 搜索、自动化设置、系统设置分别防抖。保存参数在输入时立即取快照;切页时提交该快照,不再从已切换的页面读取旧值。
|
||||
- 手动保存消费尚未执行的自动保存;缩放下拉框只提交一次。修改自动化数字时,上下文轮数未变化就不重复保存整套 AI 配置。
|
||||
- 未保存的输入保留为当前客户端页面草稿,切页回来继续编辑;后台确认同一值后释放草稿,后续云端修改仍正常显示。退出或重新加载客户端不保留未保存草稿。
|
||||
- 中文输入法组合期间不触发搜索或替换编辑区;搜索完成后更新结果,保留同一个搜索输入框和光标。
|
||||
- 切页保存各页滚动位置,避免后台更新后跳回顶部。系统缩放继续使用前一版的 DPI 适配。
|
||||
|
||||
## 验证结果
|
||||
|
||||
145 项 Python/Qt 回归通过,覆盖按页数据依赖、日志突发处理、单次保存、显示缩放、控制台、胶囊、协议回复和知识联动。最终搜索焦点改动另复跑 36 项 Qt 桥接测试通过(包含在上述范围内,不重复计数)。
|
||||
|
||||
11 项浏览器交互检查通过:草稿保留、快速切页合并、迟到快照过滤、正确值保存、去重保存、中文输入、100 次后台刷新保持输入节点与选区、保存后接收云端修改、搜索更新保留光标。
|
||||
|
||||
8 页 × 7 种显示条件共 56 组布局检查通过,无横向溢出和页面脚本异常。所有交互测试使用模拟数据和模拟桥接,没有发送真实客服消息。
|
||||
|
||||
合成文本图标替换基准:旧版中位数约 15.3 ms,新版约 8.5 ms;该数值只代表本机的字符串处理测试,不代表所有机器的界面帧率。500 条模拟归档的单次页面构建约 8.9 ms,滚动绘制延后进行。
|
||||
|
||||
## 使用和回退
|
||||
|
||||
退出并重新运行 `C:\wechat_rpa` 中的源码客户端,才会加载新界面。本次没有重新打包已有 EXE/安装包,旧 EXE 不会自动使用这些源码。
|
||||
|
||||
备份:`backups/ui-performance-20260916`。完整改动哈希和测试清单:`UI_PERFORMANCE_MANIFEST_20260916.json`。
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"backup": "C:\\wechat_rpa\\backups\\ui-performance-20260916",
|
||||
"manifest": [
|
||||
{
|
||||
"path": "test_ui_performance.py",
|
||||
"before_sha256": null,
|
||||
"sha256": "d424b40eb2a62aef9ee464fc67e9f548df7daf45564df60f276af2218a669417"
|
||||
},
|
||||
{
|
||||
"path": "wechat_gui_qt.py",
|
||||
"before_sha256": "dfa731f6121d62f77bd64e8740d2eceba3587f002272539d47aadf425a210381",
|
||||
"sha256": "cf6d9a8a4c7f17a0b3b67b466f31eed76e66bd861f497f341c711011062f6b5c"
|
||||
},
|
||||
{
|
||||
"path": "assets/ui/console-app.js",
|
||||
"before_sha256": "7ba4350adc12f61bf88f35ea9b6a78f90a104028f4382424b73614b2a8a0f43d",
|
||||
"sha256": "ba2dac17c15bb36bfa1fdb27a05a4613c9bbd33db7253a24df5415bd5000f25f"
|
||||
},
|
||||
{
|
||||
"path": "assets/ui/console-responsive.css",
|
||||
"before_sha256": "a8793d3422a096c0206d69ffc53b5925939b101fd2d0b2cc037e8681cc3bdc13",
|
||||
"sha256": "5a63d21b3dcfe6cb8f830837ee654c4fc7257b38b7f7c094bb1638abf3244308"
|
||||
},
|
||||
{
|
||||
"path": "assets/ui/zhenyang-ai-console.html",
|
||||
"before_sha256": "66897064eedf26e6cae2ce15db9b250fd5d8305c68ea0037dbecdc97a40307e5",
|
||||
"sha256": "414dc6335fe2ed66f83e75bb10372a1de2889fbac27f3bbf1353cf1f5eb7358c"
|
||||
}
|
||||
],
|
||||
"tests_passed": 145,
|
||||
"browser_interactions": [
|
||||
{
|
||||
"name": "编辑内容在后台刷新、失焦后保留",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "切页返回仍保留未保存草稿",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "快速连续导航只提交最后一页",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "旧快照不会把新页面切回去",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "切页前提交编辑快照,不误保存旧值",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "缩放下拉选择只保存一次",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "手动保存会消费待执行的自动保存",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "中文输入组合期间不提交搜索",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "编辑时 100 次后台状态更新保持输入节点和光标",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "保存确认后接收后续云端修改",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"name": "搜索结果刷新保留正在输入的搜索框和光标",
|
||||
"passed": true
|
||||
}
|
||||
],
|
||||
"layout_checks": 56,
|
||||
"artifacts": "C:\\kefu\\deploy\\ui-performance-20260916",
|
||||
"installer_rebuilt": false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
> v1.4.15 更新:两个版本已整合到主程序,5.0.11 首次发送验证在同一界面完成,取得真实回执后启用当前账号。最新说明见 [DUAL_VERSION_COMPATIBILITY.md](DUAL_VERSION_COMPATIBILITY.md)。以下内容保留此前阶段的验证记录。
|
||||
|
||||
# 企业微信 5.0.11.6018 兼容性进展(2026-09-15)
|
||||
|
||||
状态:候选适配已实现,目标客户端真实收发尚未验收,未发布新的正式安装包。
|
||||
|
||||
## 本次故障证据
|
||||
|
||||
用户提供的 runtime-log-20260915-143126.txt、runtime-log-20260915-143558.txt 和截图均显示企业微信为 5.0.11.6018 x86。v1.4.14 只支持 5.0.10.6023 x86,因此检测首先被版本限制阻止,尚未进入当前账号、数据库和发送检查。不同安装位置已经成功识别;这两份日志不能证明更换账号可解决问题,也不能证明后续检查一定通过。
|
||||
|
||||
## 实现与证据
|
||||
|
||||
- 从腾讯官方下载并校验 5.0.11.6018 安装程序和 WXWork.exe 的有效 Authenticode 签名。未执行安装程序,未升级本机企业微信。
|
||||
- 以精确文件 SHA-256 选择版本布局。新版不复用旧版地址或仅放开版本号。
|
||||
- 新版发送函数新增第四个参数,callee 从 ret 12 改为 ret 16;新调用代码提供零初始化的可选字符串参数。
|
||||
- 消息工厂和发送回执 promise 构造代码在移除重定位差异后与旧版一致;这属于静态证据,不是服务器回执。
|
||||
- x86 CPU 模拟验证两个版本的参数顺序、栈平衡、寄存器保存、账号变化和错误消息类型拦截。模拟不向真实客户发消息。
|
||||
- 新版仍标记 verified=False。自动回复不能凭检测布尔值或版本号启动;只有明确的单次验证入口可执行候选版本测试。
|
||||
- 验证入口绑定预期客户端版本和账号,唯一匹配现有单聊,检测联系人验证提示;固定测试文本为“协议通道测试,请忽略”。记录去重请求和服务器回执,结果不明时不重发。
|
||||
|
||||
## 已完成验证
|
||||
|
||||
- 651 项相关回归通过:tmp/wecom-5.0.11/regression.log。后续验证工具交互和输出目录调整对应的 8 项测试另行通过。
|
||||
- 当前本机 5.0.10.6023 只读检测仍返回 native_ready,当前账号与数据库可读取;本次未发送真实消息。
|
||||
- 独立验证工具已打包为 onedir,依赖齐全,不需要目标电脑安装 Python。
|
||||
- ZIP 完整性检查通过,没有加入账号密钥、用户配置或聊天数据库。
|
||||
- ZIP 解压到中文、空格目录,使用全新 LOCALAPPDATA 和仅包含 Windows 系统目录的 PATH,成品自检通过;成品环境只读检测也完成。此项不是另一台物理电脑测试。
|
||||
- Defender 文件扫描未检出威胁,保护保持启用。历史原生调用行为告警和微软复核仍未解决,不应将文件扫描通过描述为行为验证通过。
|
||||
|
||||
## 待完成
|
||||
|
||||
当前开发电脑不能升级;用户已明确这一限制。需要在已有 5.0.11.6018 的目标电脑运行验证工具并取得真实回执,再决定是否将该构建标为已验证并重新生成正式安装包。没有跨设备访问和回执时,不能声称两台电脑均已修好。
|
||||
|
||||
验证包:dist/validation/企业微信5.0.11-收发验证包.zip。
|
||||
|
||||
解压后运行 WeCom-6018-Validation.exe,暂停原客服程序监听,输入该账号已有的专用测试会话并确认测试发送。报告默认保存在工具旁的 reports 文件夹;目录不可写时使用当前用户应用数据目录。返回报告即可,不需要提供数据库或密钥。
|
||||
|
||||
机器可读验证记录:wecom_6018_validation_acceptance.json。源码摘要:tmp/wecom-5.0.11/source-manifest.json。
|
||||
@@ -0,0 +1,32 @@
|
||||
> 2026-09-15 更新:当前监听运行在 `logs/wecom_process_monitor/2026-09-15-startup`。下文根目录记录属于昨晚已结束的一轮。今天的状态、结论和正确停止命令见 [续查报告](C:/wechat_rpa/WECOM_PROCESS_OBSERVATION_20260915.md)。
|
||||
|
||||
# 企业微信进程监听
|
||||
|
||||
已启动只读后台监听,每 0.5 秒采样一次,当前这一轮最长运行两小时。以 `logs/wecom_process_monitor/status.json` 的 `startedAt` 和 `durationSeconds` 为准。状态时间使用 UTC;北京时间为 UTC+8。
|
||||
|
||||
观察范围:企业微信主程序及其子进程、官方代理和 IronBox 程序、相关模块名称、TCP/UDP 端点和连接状态变化。仅通过 Windows 进程元数据和网络表查询,未读取进程内存、命令行参数、环境变量、通道密钥或聊天内容,未注入代码、未发送探测包或客户消息。
|
||||
|
||||
当前账号不能订阅 WMI ProcessTrace(拒绝访问),因此采用快照采样。生命周期短于采样间隔的进程、模块和网络连接可能漏掉。启动时已有的进程标为 `process_observed`,随后观察到的新进程才标为 `process_started`。模块或网络表查询失败会单独报告,不将查询失败当成已关闭。
|
||||
|
||||
## 查看记录
|
||||
|
||||
- 实时状态:`C:\wechat_rpa\logs\wecom_process_monitor\status.json`
|
||||
- 最新快照:`C:\wechat_rpa\logs\wecom_process_monitor\snapshot.json`
|
||||
- 变化日志:`C:\wechat_rpa\logs\wecom_process_monitor\events.jsonl`
|
||||
- 错误输出:`C:\wechat_rpa\logs\wecom_process_monitor\monitor.stderr.log`
|
||||
|
||||
事件日志超过 4 MiB 时轮转,保留当前文件和上一份。监听器有输出目录互斥保护,重复启动不会产生第二个记录器。停止只向该轮监听器写入停止请求,不会终止企业微信。
|
||||
|
||||
## 手动停止
|
||||
|
||||
在 PowerShell 执行:
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\isard\AppData\Local\Programs\Python\Python311\python.exe' 'C:\wechat_rpa\wxwork_process_monitor.py' --stop
|
||||
```
|
||||
|
||||
## 验证与能力边界
|
||||
|
||||
已通过 6 项针对进程 PID 复用、连接状态变化、日志轮转、Windows 文件占用的测试,并实测后台启动、重复启动保护和正常停止。当前运行状态可在上述状态文件查看。
|
||||
|
||||
这项监听用于寻找官方代理正常启动和端口变化的证据,不等于协议登录或原账号发送已经验证。协议自动回复仍未启用。本轮没有执行此前被拒绝的密钥读取/子进程注入测试,也未重新打包 EXE。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 企业微信进程监听续查(2026-09-15)
|
||||
|
||||
**结论:昨晚两小时采样和今天正常启动后的观察,均未观察到 WeComAgent / IronBox 代理运行。已核对的模块、命令转发器和正常深链接中,尚未验证可直接使用的原员工账号单聊发送接口。协议自动回复未打通,没有向测试会话发送消息。**
|
||||
|
||||
## 昨晚的实际记录
|
||||
|
||||
- 北京时间 2026-09-14 18:16:16 至 20:16:17,共 14,161 次快照,按计划结束,错误输出为空。
|
||||
- 基线包含 4 个 WXWork.exe 及 20 个相关子进程。未观察到其后的进程启停事件,也未观察到新的监听端口。
|
||||
- 18:17:56 观察到主进程加载 `native.dll`。静态查看其 159 个导出名称,接口集中于文本测量、布局、渲染、资源及 XML/文档处理;未发现可以直接使用的聊天发送或 MCP 导出入口。文件 SHA-256 为 `ccd3615e757d276d4efd5f93f0b4cc37408f39862ceae6141838b43d66ba2019`。
|
||||
- 连接变化存在,但进程元数据和系统网络表不含消息正文或应用层请求,不能据此恢复加密协议。
|
||||
|
||||
## 今天的实际工作
|
||||
|
||||
- 电脑于 06:15:42 重启;开始续查时企业微信未运行。已按安装路径正常启动 5.0.10.6023,并恢复只读监听。
|
||||
- 当前主客户端 PID 7528,监控 PID 8436。当前有 17 个目标进程及子进程,采样 635 次,未记录到 WeComAgent / IronBox。以实时状态文件为准。
|
||||
- 当前本机 9882、9883、50010、50018 端口恢复监听。端口出现仅代表服务存在,不是 SDK 身份或发送能力验证。
|
||||
- 本轮每 0.5 秒采样,预计北京时间 **2026-09-15 10:52:07** 自动结束。采样可能遗漏短暂存在的进程或连接。
|
||||
|
||||
## 继续排除的入口
|
||||
|
||||
- `WXWorkCommand.exe`:静态代码显示其解析命令行,通过命名共享映射及注册窗口消息激活/转交客户端命令;其中 `SendMessageTimeoutW` 是 Windows 窗口通知 API,不能据此当作聊天发送接口。未执行未知命令。
|
||||
- `wxwork://forwardmsg`:定位到 `SchemaFilterWeChatForward` 和 `kReceiveWeChatForwardSchemaNotification`,属于接收微信转发数据的处理入口。未验证它具有任意文本的原账号直接发送能力,未触发转发。
|
||||
- 官方公开 CLI 的当前消息说明为向机器人最近参与的单聊/群聊推送。它仍不能直接满足本项目要求的原员工账号回复已有客户:[WecomTeam/wecom-cli](https://github.com/WecomTeam/wecom-cli)、[WecomTeam/wecom-unified](https://github.com/WecomTeam/wecom-unified)。
|
||||
|
||||
本轮未读取进程内存、密钥或环境变量,未注入代码,未执行此前审批拒绝的临时桥接测试;没有修改企业微信程序,也未重新打包 EXE。
|
||||
|
||||
## 当前记录及停止方式
|
||||
|
||||
- [当前监听状态](C:/wechat_rpa/logs/wecom_process_monitor/2026-09-15-startup/status.json)
|
||||
- [当前事件日志](C:/wechat_rpa/logs/wecom_process_monitor/2026-09-15-startup/events.jsonl)
|
||||
- [昨晚汇总](C:/wechat_rpa/tmp/wecom-overnight-observation.json)
|
||||
- [今天采样汇总](C:/wechat_rpa/tmp/wecom-startup-monitor-result.json)
|
||||
- [模块分析](C:/wechat_rpa/tmp/wecom-startup-native-module-analysis.json)
|
||||
- [命令转发器分析](C:/wechat_rpa/tmp/wecom-command-interface-analysis.json)
|
||||
- [正常 URI 处理器静态证据](C:/wechat_rpa/tmp/wecom-normal-message-uri-handlers.json)
|
||||
|
||||
停止今天这一轮(不会停止企业微信):
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\isard\AppData\Local\Programs\Python\Python311\python.exe' 'C:\wechat_rpa\wxwork_process_monitor.py' --stop --output 'C:\wechat_rpa\logs\wecom_process_monitor\2026-09-15-startup'
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
# Windows EXE 与安装包构建
|
||||
|
||||
双击 `一键生成安装包.bat`(`一键打包EXE.bat` 当前也生成完整安装包)。
|
||||
也可在项目目录执行:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\build_installer.ps1
|
||||
```
|
||||
|
||||
脚本为本地构建进程设置执行方式,不修改系统执行策略或杀毒软件设置。
|
||||
首次构建需要 Python 3.11/3.12 并可能联网安装依赖;已有 `.build-venv` 可直接复用。
|
||||
仅构建应用目录时运行 `build_windows_exe.ps1`,可用 `-PythonPath` 指定 Python。
|
||||
|
||||
## 构建输入与输出
|
||||
|
||||
版本取自 `app_version.py`。默认归档源码在本项目的 `archive_exporter` 中,
|
||||
不再依赖旧电脑上的 `2026-08-19-18-27-34` 目录。构建前会验证四个源码文件及语法。
|
||||
有意使用自有导出器时可设置 `WECOM_BUILD_EXPORTER_DIR`;失效配置会明确报错。
|
||||
该变量只影响构建,不影响安装后从包内加载导出器。
|
||||
|
||||
输出:
|
||||
|
||||
- 应用目录:`dist\ZhenAI-WeCom-Assistant-v<版本>\`
|
||||
- 安装包:`dist\installer\甄AI客服-安装包-v<版本>.exe`
|
||||
|
||||
应用采用目录形式,主 EXE 必须与 `_internal` 和更新器一起使用,不能只复制主 EXE。
|
||||
安装包包含这些文件,支持 Windows 10/11 64 位;目标电脑无需 Python 或 WebView2。
|
||||
构建脚本验证 Qt WebEngine、中文资源、VC/OpenSSL 运行库,并在精简 PATH 下运行
|
||||
打包自检;归档、导航及发送保护使用合成数据和模拟输入,不向真实会话发送消息。
|
||||
安装器默认安装到当前用户目录,创建桌面和开始菜单快捷方式,不要求管理员权限。
|
||||
|
||||
## 数据与升级
|
||||
|
||||
归档组件按四文件白名单收取代码,不分发开发机的密钥、账号配置或聊天数据库。
|
||||
安装后的配置与聊天资料保存于 `%LOCALAPPDATA%\ZhenYangTangRPA`,更新及卸载时保留。
|
||||
读取企业微信加密数据库仍需目标电脑对应账号的密钥。
|
||||
|
||||
更改版本后重新构建;构建不会自动上传或发布。正式发布时使用 HTTPS 安装包地址和
|
||||
SHA256,云端升级版本必须高于已安装版本。先在目标环境验收再发布更新。
|
||||
|
||||
## 签名、杀毒告警与验收
|
||||
|
||||
签名用于确认发布者及文件完整性,不等同于杀毒软件审核;不能保证消除行为告警。
|
||||
可配置已有代码签名证书:`WECOM_CODESIGN_THUMBPRINT`,或 `WECOM_CODESIGN_PFX` 与
|
||||
`WECOM_CODESIGN_PFX_PASSWORD`。无证书时构建仍可完成,但产物会保持未签名。
|
||||
不要在源码或构建日志中保存证书密码。
|
||||
|
||||
2026-09-15:1.4.11 出现 `Behavior:Win32/DefenseEvasion.A!ml` 行为检测并被隔离。
|
||||
1.4.12 修复构建依赖,按要求保留视觉和协议实现;构建成功不能证明该告警已解决。
|
||||
请查阅 `SECURITY_REVIEW_20260915.md` 的实际扫描、运行验证和复核状态。
|
||||
检测到行为告警时应保留报告并交由厂商分析,不应关闭防护、添加排除项或更换包装以规避检测。
|
||||
@@ -0,0 +1,132 @@
|
||||
# 本机企业微信协议分析与当前实现
|
||||
|
||||
日期:2026-09-14
|
||||
结论:已实现内置 SDK 的通信层和诊断接入;**本机 SDK 运行时尚未连通,原账号客户单聊发送、身份和回执均未验证,P0 未通过,暂不能使用协议自动回复。**
|
||||
|
||||
最新直接协议尝试见 [本机加密协议实测](NATIVE_PROTOCOL_TRIAL.md):已定位本地 TCP 加密结构并实际尝试初始化,连接在发送数据前被关闭,尚未进入消息发送阶段。
|
||||
|
||||
## 用户反馈后的复查(17:19)
|
||||
|
||||
用户截图确认协议自动回复不可用。**这不是用户漏配参数:本项目尚未实现并验证适配 5.0.10.6023 的原账号发送器。** 上一轮的通信层测试、EXE 构建与检测成功均不能表示核心需求完成。将内置 SDK 视作可直接接入的员工单聊发送通道,目前证据不足。
|
||||
|
||||
本次实际修复:页头操作区缺省值导致右上角显示 undefined;已修改实际加载的 console-app.js,并同步到用户正在运行的 dist/ZhenAI-WeCom-Assistant-v1.4.9/_internal/assets/ui/console-app.js。SDK 未连接徽标不再提示用户等待授权启动。52 项相关页面与控制台回归通过,真实 Qt 页面断言包含不显示 undefined。现有 EXE 主体未重新编译;重新打开应用后加载更新的资源。没有重启用户的监听,没有发送真实消息。
|
||||
|
||||
进一步核对:
|
||||
|
||||
- 客户端代理启动代码包含灰度开关、系统配置与签名检查;客户端负责生成 agent-client-config.json 与工作目录。静态分支只能证明存在这些条件,不能推断本账号开关实际值。
|
||||
- 当前没有运行的 wecom-agent.exe;在已检查的 WXWork/Tencent 专用文档、Roaming、Local、ProgramData 目录中未发现 agent-client-config.json 或 openclaw.json。独立 EXE 无法凭程序已安装就自动继承代理 Python 工具的 RPC 参数。
|
||||
- 内置代理中可见 aibot_send_msg 字符串,没有定位到旧 CLI 文档中三个消息命令的同名字符串;字符串扫描不足以证明方法不存在,也不足以确定原员工发送作用域。
|
||||
- 已核对 [wxhook](https://github.com/miloira/wxhook) 的企微版本说明,列出至 5.0.3.6005;其公开示例主要是个人微信。另两个候选 [WeChatWork-API](https://github.com/lyx102/WeChatWork-API) 和 [wxwork_pc_api](https://github.com/GeassV/wxwork_pc_api) 分别声明 5.0.0、3.0.27.2701。它们均未提供本次已验证的 5.0.10.6023 原账号收发适配。没有将旧版 DLL 注入当前客户端,也没有降级现有企业微信。
|
||||
|
||||
后续需完成当前版本内部发送接口的独立适配,或取得确切支持本版本的原账号桥接实现,再进行已授权的目标会话实测。当前没有可以通过修改页面状态或填写猜测参数来启用协议发送的已知方法。
|
||||
|
||||
本次证据:tmp/protocol-native-launch-strings.json、tmp/protocol-followup-launch-evidence.json、tmp/protocol-bundled-cli-send-contract.json、tmp/protocol-followup-ui-validation.json;改动前备份在 tmp/protocol-followup-before。
|
||||
|
||||
## 本轮实际结果
|
||||
|
||||
| 验收项 | 结果与证据 |
|
||||
| --- | --- |
|
||||
| 安装组件 | 企业微信 5.0.10.6023,x86;发现 WeComAgent 程序和 Python SDK 源码 |
|
||||
| 通信契约 | 已按安装包 `_rpc.py` 实现 TCP NDJSON 握手及 `wecom.exec` 请求/响应 |
|
||||
| 本机 SDK 连接 | 未获得正常运行时提供的连接参数;实际报告为 `sdk_runtime_unavailable` |
|
||||
| 正常启动入口 | 用户反馈未看到“智能助手/本地智能体(WeClaw)”入口;没有确认其他可用的正常启动方式 |
|
||||
| 原账号、原客户会话 | 尚未由在线 SDK 验证;历史数据库记录不能证明当前在线身份 |
|
||||
| 真实文本发送 | 未执行;用户已指定可用测试会话“小迷糊”或“高瑞”,预定文本“协议通道测试,请忽略” |
|
||||
| 发送回执 | 未验证;没有已关联到原账号、原会话和发送事务的服务端消息 ID |
|
||||
| 产品状态 | 显示“SDK 运行时待连接”;协议启动仍被拦截,`canSend`、`sendVerified`、`receiptVerified` 均为 false |
|
||||
| EXE | 已构建 1.4.9,包内自检和精简 PATH 下的实际 EXE 协议诊断通过;协议能力仍不可用 |
|
||||
|
||||
没有看到入口不能单独证明账号不在灰度范围,连接重置也不能单独证明某种加密配置已启用。当前能够确认的限制是:本项目没有获得客户端正常提供的 SDK 运行时连接参数,也没有成功握手或发送证据。
|
||||
|
||||
## 已实现功能
|
||||
|
||||
| 功能 | 当前行为 |
|
||||
| --- | --- |
|
||||
| 独立回复通道 | 自动化设置中分别保存视觉/协议模式与自动发送/人工审核模式 |
|
||||
| SDK 通信层 | 仅连接本机回环地址;支持带令牌的 hello/welcome 握手、顺序请求 ID、UTF-8 和有界响应帧 |
|
||||
| SDK 异常处理 | 拒绝无效地址、无效响应和错误 ID;请求写出后断连/超时标记为可能已执行,不自动重发 |
|
||||
| 只读运行时探测 | 有正常运行时参数时,仅握手并请求 `--help`;RPC 连通也不标记登录、发送或回执已验证 |
|
||||
| 后台检测与状态 | 读取企业微信进程、版本、PE 架构、程序文件及内置 SDK;增加独立 SDK 连接状态 |
|
||||
| 报告导出 | JSON 原子写入;包含元数据与诊断,不记录令牌值、账号目录、聊天正文 |
|
||||
| EXE 诊断命令 | 新增 `--protocol-diagnostics REPORT.json`,在创建 Qt 界面、登录及监听前执行诊断后退出 |
|
||||
| 启动保护 | 未适配时禁止协议启动,后台线程在构造视觉机器人之前独立拦截 |
|
||||
| 配置保护 | 监听时禁止切换通道;未知通道不回退到视觉;配置写入失败恢复内存值 |
|
||||
| 精确上下文 | 增加按 account + conv_id 查询的接口;区分同名客户与不同账号,保留数据库读取异常 |
|
||||
| 构建路径 | 为外置归档源码增加仅用于构建的 `WECOM_BUILD_EXPORTER_DIR`,不影响包内运行时发现与自检 |
|
||||
|
||||
入口:Qt 客户端 → **自动化设置 → 回复通道 → 检测本机协议**。选择协议模式后,“启动”只执行检测;视觉模式继续沿用现有能力。
|
||||
|
||||
本轮代码:wecom_sdk_client.py、wxwork_protocol_probe.py、wechat_gui_qt.py、assets/ui/console-app.js、wechat_rpa.spec、test_wecom_sdk_client.py。前轮已改 reply_transport.py、gui_runtime.py、wxwork_db.py、reply_database.py、wechat_gui.py 等基础模块。
|
||||
|
||||
## 本机接口证据
|
||||
|
||||
实际程序:`C:\Program Files (x86)\WXWork\WXWork.exe`,版本 **5.0.10.6023**,架构 **x86 / 32 位**。SHA-256:`47b5dfafc8def0b41a648610f1ba08bbf15f710c6f81c08d36c485bd82ce2842`。当前项目 Python 是 64 位,不能直接在其中加载该客户端的 32 位 DLL。
|
||||
|
||||
安装目录中的 SDK:`5.0.10.6023\WeComAgent\python\3.10\site-packages\wecomagent\_rpc.py`。源码说明代理向其启动的 Python 工具注入 `WECOMAGENT_RPC_HOST`、`WECOMAGENT_RPC_PORT`、`WECOMAGENT_RPC_TOKEN`;先发送 hello 并接收 welcome,再以 `wecom.exec` 和 `argv` 调用。已实现该通信契约,但该 Python 文件本身没有提供已验证的原员工客户单聊发送方法。
|
||||
|
||||
主程序静态信息包含 MCP/WeClaw 服务、TCP AES 密钥生成分支、功能开关和连接签名检查。观察到的回环监听包括 9882、9883、50018、50010;普通 HTTP GET、JSON-RPC initialize 和 TLS 连接尝试均被重置/中断,没有获得标准 MCP 会话、工具列表或 SDK 授权参数。**这是普通连接未能建立的证据,不是成功识别或解密传输协议的证据。**
|
||||
|
||||
程序中出现 SendTextMessage、sendChatMessage、WeClawMcpToolManager 等名字,涉及 RTTI、JS API 处理器和内部日志;没有据此确认可调用函数签名、参数结构或原账号发送语义。WXWorkCommand.exe 的已观察命令与云盘、图片预览有关,未发现已验证的文本发送命令。
|
||||
|
||||
前次运行 `wecom-agent.exe --help` 没有返回帮助,而是初始化独立工作目录;该次进程已停止,生成的工作目录已清理。本轮未继续直接启动该程序,未修改客户端功能开关或关闭连接认证。
|
||||
|
||||
## 公开接口交叉核对
|
||||
|
||||
[WecomTeam/wecom-cli 当前仓库](https://github.com/WecomTeam/wecom-cli)提供的消息能力围绕机器人会话与身份,不能证明能以当前员工身份回复已有外部联系人;当前动态服务发现也不等于本机内置 SDK 已开放同一接口。
|
||||
|
||||
历史版本曾记录 [`msg send_message`](https://github.com/WecomTeam/wecom-cli/blob/9eb7898b959861af879495e211e37431fa908f19/skills/wecomcli-msg/references/send-message.md):其说明中的返回仅有 errcode/errmsg。即使这些旧命令仍可调用,也还需要核对身份、外部联系人作用域和可关联的消息结果,不能把旧文档中的“成功”直接当作本项目已验证的发送回执。因此没有按历史接口猜测参数向客户试发。
|
||||
|
||||
## 验证记录
|
||||
|
||||
相关回归 **118 项通过**,JavaScript 语法检查通过。包含:
|
||||
|
||||
- SDK 实际回环 socket 测试:握手、Unicode 请求、认证拒绝、断连、超时、错误/布尔 ID、非法 JSON、超大帧、并发串行化及不自动重发。
|
||||
- SDK 诊断只请求帮助,不误报账号登录、发送和回执;导出不包含测试令牌。
|
||||
- EXE 诊断入口不创建 QApplication/MainWindow,不启动监听。
|
||||
- 同名客户、跨账号和相同 server_id 的上下文隔离,数据库异常传播。
|
||||
- 通道配置与启动拦截、保存失败恢复、真实 Qt WebEngine 页面按钮及既有数据库/控制台回归。
|
||||
|
||||
SDK 回环测试使用隔离的模拟服务。**这些测试验证代码行为,不能替代本机 SDK 实连或原账号真实发送验收。**
|
||||
|
||||
~~~powershell
|
||||
python -m pytest test_wecom_sdk_client.py test_protocol_features.py test_database_reply_priority.py test_runtime_settings.py test_qt_console_bridge.py -q
|
||||
node --check assets\ui\console-app.js
|
||||
python wxwork_protocol_probe.py --deep --output protocol_client_diagnostics.json
|
||||
~~~
|
||||
|
||||
当前实机诊断见 [protocol_client_diagnostics.json](protocol_client_diagnostics.json)。诊断退出码 0 仅表示分析完成,不能据此判断协议可发送;应读取 canSend、sdkRuntime.rpcVerified、sendVerified、receiptVerified。
|
||||
|
||||
本机调查中间证据位于 tmp/wecom-sdk-runtime-check.json、tmp/wecom-mcp-initialize-check.json、tmp/wecom-local-tls-check.json 及 tmp/native-mcp-server-evidence.json。代码备份位于 tmp/protocol-before 和 tmp/protocol-sdk-before。
|
||||
|
||||
## EXE 产物与复现
|
||||
|
||||
程序:[ZhenAI-WeCom-Assistant-v1.4.9.exe](dist/ZhenAI-WeCom-Assistant-v1.4.9/ZhenAI-WeCom-Assistant-v1.4.9.exe)。这是目录版,**移动或分发时保留整个 ZhenAI-WeCom-Assistant-v1.4.9 文件夹,不能单独复制 EXE**。包内附有“协议功能说明.txt”。
|
||||
|
||||
- 主 EXE 大小:17,137,545 字节。
|
||||
- SHA-256:`ddd7717e9c16683155b98f29fb8274a27a8ee1d141772a94add30c5c6486ba5e`。
|
||||
- 构建使用 Python 3.11.9、PyInstaller 6.22.2;构建脚本退出码 0。
|
||||
- 包内 Qt WebEngine、自带运行库、HTTPS/证书验证与既有归档/数据库/视觉发送保护自检通过;业务模拟检查不等于真实客户发送验收。
|
||||
- 独立更新器已打入目录;未配置签名证书,构建按原脚本跳过签名。
|
||||
- 已核对 wecom_sdk_client、wxwork_protocol_probe 被收集,归档目录仅包含四个指定源码文件。
|
||||
- 新 EXE 在精简 PATH 中实际运行协议诊断,退出码 0;报告仍为 `sdk_runtime_unavailable`,发送与回执标志均为 false。
|
||||
|
||||
实际结果:[protocol_build_validation.json](protocol_build_validation.json)、[新 EXE 诊断报告](tmp/packaged-protocol-report.json)、[构建日志](tmp/protocol-exe-build.log)。
|
||||
|
||||
本机构建时原脚本引用的相邻归档源码目录不存在;已在 `C:\kefu\2026-08-19-18-27-34` 找到原有组件。新增的是构建专用路径覆盖,运行时仍检查包内文件。
|
||||
|
||||
~~~powershell
|
||||
$env:WECOM_BUILD_EXPORTER_DIR = 'C:\kefu\2026-08-19-18-27-34'
|
||||
.\build_windows_exe.ps1 -PythonPath 'C:\Users\isard\AppData\Local\Programs\Python\Python311\python.exe'
|
||||
# EXE 只读诊断:不启动监听,不发送消息
|
||||
& .\dist\ZhenAI-WeCom-Assistant-v1.4.9\ZhenAI-WeCom-Assistant-v1.4.9.exe --protocol-diagnostics C:\wechat_rpa\tmp\packaged-protocol-report.json
|
||||
~~~
|
||||
|
||||
## 尚未完成与下一步
|
||||
|
||||
1. 获得客户端正常启动的 SDK 环境并成功握手;继续验证 CLI schema/帮助与接口作用域。
|
||||
2. 读取在线员工身份,建立与数据库 account、客户 conv_id 的明确映射,不能只按显示名称试发。
|
||||
3. 在用户已授权的“小迷糊”或“高瑞”会话中执行一次指定文本测试,验证原账号、原会话。
|
||||
4. 用返回值及后续消息证据确认发送事务;区分请求受理、确认发送、拒绝和结果不明。结果不明不能自动重发。
|
||||
5. P0 通过后再实现协议调度、持久发送账本、AI/审核、人工接管和暂停恢复,完成协议自动回复闭环。
|
||||
|
||||
本轮交付属于 SDK 通信与协议诊断实现,不能标为原账号协议自动回复已完成。
|
||||
@@ -0,0 +1,4 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
not ie 11
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/commitlint-config';
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
dist
|
||||
.turbo
|
||||
dist.zip
|
||||
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
insert_final_newline=true
|
||||
indent_style=space
|
||||
indent_size=2
|
||||
max_line_length = 100
|
||||
trim_trailing_whitespace = true
|
||||
quote_type = single
|
||||
|
||||
[*.{yml,yaml,json}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,21 @@
|
||||
# https://docs.github.com/cn/get-started/getting-started-with-git/configuring-git-to-handle-line-endings
|
||||
|
||||
# Automatically normalize line endings (to LF) for all text-based files.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
*.cmd text eol=crlf
|
||||
*.[cC][mM][dD] text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.[bB][aA][tT] text eol=crlf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.ico binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,2 @@
|
||||
[core]
|
||||
ignorecase = false
|
||||
@@ -0,0 +1,63 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
dist.zip
|
||||
dist.tar
|
||||
dist.war
|
||||
.nitro
|
||||
.output
|
||||
*-dist.zip
|
||||
*-dist.tar
|
||||
*-dist.war
|
||||
coverage
|
||||
*.local
|
||||
**/.vitepress/cache
|
||||
.cache
|
||||
.turbo
|
||||
.temp
|
||||
dev-dist
|
||||
.stylelintcache
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
.VSCodeCounter
|
||||
**/backend-mock/data
|
||||
.omx
|
||||
.pnpm-store
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
.eslintcache
|
||||
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
vite.config.mts.*
|
||||
vite.config.mjs.*
|
||||
vite.config.js.*
|
||||
vite.config.ts.*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
# .vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.history
|
||||
.cursor
|
||||
|
||||
# AI
|
||||
.agent
|
||||
.agents
|
||||
.claude
|
||||
.codex
|
||||
skills-lock.json
|
||||
.atomcode
|
||||
datalog
|
||||
.playwright-mcp
|
||||
@@ -0,0 +1,6 @@
|
||||
ports:
|
||||
- port: 5555
|
||||
onOpen: open-preview
|
||||
tasks:
|
||||
- init: npm i -g corepack && pnpm install
|
||||
command: pnpm run dev:play
|
||||
@@ -0,0 +1 @@
|
||||
24.16.0
|
||||
@@ -0,0 +1,14 @@
|
||||
# registry.npmjs.org 在这台机器上完全不通(curl 直接超时),而 lockfile 里锁的
|
||||
# 本来就是 npmmirror 的地址。两边不一致时 pnpm 会去撞不通的那个,表现是零星几
|
||||
# 个大包反复超时,而大部分包因为有缓存又能装上——很难一眼看出是源的问题。
|
||||
registry=https://registry.npmmirror.com
|
||||
# 大二进制包(echarts / turbo / rolldown 的平台产物)在国内镜像上经常拉超时。
|
||||
# 默认 60 秒不够,给足并降低并发,避免同时抢带宽把每一路都拖到超时。
|
||||
fetch-timeout=600000
|
||||
fetch-retries=6
|
||||
fetch-retry-maxtimeout=120000
|
||||
network-concurrency=6
|
||||
|
||||
# 本机 corepack 提供的是 pnpm 11.5.1,项目钉的是 11.16.0。
|
||||
# 版本差异只影响 lockfile 格式的边角,不值得让 postinstall 整个失败——降成警告。
|
||||
pm-on-fail=warn
|
||||
@@ -0,0 +1,8 @@
|
||||
dist
|
||||
public
|
||||
__tests__
|
||||
coverage
|
||||
.codex
|
||||
.claude
|
||||
.agent
|
||||
.agents
|
||||
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present, Vben
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,157 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/anncwb/vue-vben-admin">
|
||||
<img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp">
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
<h1>Vue Vben Admin</h1>
|
||||
</div>
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin)    
|
||||
|
||||
**日本語** | [English](./README.md) | [中文](./README.zh-CN.md)
|
||||
|
||||
## 紹介
|
||||
|
||||
Vue Vben Adminは、最新の`vue3`、`vite`、`TypeScript`などの主流技術を使用して開発された、無料でオープンソースの中・後端テンプレートです。すぐに使える中・後端のフロントエンドソリューションとして、学習の参考にもなります。
|
||||
|
||||
## アップグレード通知
|
||||
|
||||
これは最新バージョン `5.0` であり、以前のバージョンとは互換性がありません。新しいプロジェクトを開始する場合は、最新バージョンを使用することをお勧めします。古いバージョンを表示したい場合は、[v2ブランチ](https://github.com/vbenjs/vue-vben-admin/tree/v2)を使用してください。
|
||||
|
||||
## 特徴
|
||||
|
||||
- **最新技術スタック**:Vue 3やViteなどの最先端フロントエンド技術で開発
|
||||
- **TypeScript**:アプリケーション規模のJavaScriptのための言語
|
||||
- **テーマ**:複数のテーマカラーが利用可能で、カスタマイズオプションも豊富
|
||||
- **国際化**:完全な内蔵国際化サポート
|
||||
- **権限管理**:動的ルートベースの権限生成ソリューションを内蔵
|
||||
|
||||
## プレビュー
|
||||
|
||||
- [Vben Admin](https://vben.pro/) - フルバージョンの中国語サイト
|
||||
|
||||
テストアカウント:vben/123456
|
||||
|
||||
<div align="center">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview1.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview2.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview3.png">
|
||||
</div>
|
||||
|
||||
### Gitpodを使用
|
||||
|
||||
Gitpod(GitHub用の無料オンライン開発環境)でプロジェクトを開き、すぐにコーディングを開始します。
|
||||
|
||||
[](https://gitpod.io/#https://github.com/vbenjs/vue-vben-admin)
|
||||
|
||||
## ドキュメント
|
||||
|
||||
[ドキュメント](https://doc.vben.pro/)
|
||||
|
||||
## インストールと使用
|
||||
|
||||
1. プロジェクトコードを取得
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vbenjs/vue-vben-admin.git
|
||||
```
|
||||
|
||||
2. 依存関係のインストール
|
||||
|
||||
```bash
|
||||
cd vue-vben-admin
|
||||
npm i -g corepack
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 実行
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. ビルド
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 変更ログ
|
||||
|
||||
[CHANGELOG](https://github.com/vbenjs/vue-vben-admin/releases)
|
||||
|
||||
## 貢献方法
|
||||
|
||||
ご参加をお待ちしておりますするか、Pull Requestを送信してください。
|
||||
|
||||
**Pull Request プロセス:**
|
||||
|
||||
1. コードをフォーク
|
||||
2. 自分のブランチを作成:`git checkout -b feat/xxxx`
|
||||
3. 変更をコミット:`git commit -am 'feat(function): add xxxxx'`
|
||||
4. ブランチをプッシュ:`git push origin feat/xxxx`
|
||||
5. `pull request`を送信
|
||||
|
||||
## Git貢献提出規則
|
||||
|
||||
参考 [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) 規則 ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular))
|
||||
|
||||
- `feat` 新機能の追加
|
||||
- `fix` 問題/バグの修正
|
||||
- `style` コードスタイルに関連し、実行結果に影響しない
|
||||
- `perf` 最適化/パフォーマンス向上
|
||||
- `refactor` リファクタリング
|
||||
- `revert` 変更の取り消し
|
||||
- `test` テスト関連
|
||||
- `docs` ドキュメント/注釈
|
||||
- `chore` 依存関係の更新/スキャフォールディング設定の変更など
|
||||
- `ci` 継続的インテグレーション
|
||||
- `types` 型定義ファイルの変更
|
||||
|
||||
## ブラウザサポート
|
||||
|
||||
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
|
||||
|
||||
モダンブラウザをサポートし、IEはサポートしません
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| :-: | :-: | :-: | :-: |
|
||||
| 最新2バージョン | 最新2バージョン | 最新2バージョン | 最新2バージョン |
|
||||
|
||||
## メンテナー
|
||||
|
||||
[@Vben](https://github.com/anncwb)
|
||||
|
||||
## スター歴史
|
||||
|
||||
[](https://star-history.dera.page/#vbenjs/vue-vben-admin&Date)
|
||||
|
||||
## 寄付
|
||||
|
||||
このプロジェクトが役に立つと思われた場合、作者にコーヒーを一杯おごってサポートを示すことができます!
|
||||
|
||||

|
||||
|
||||
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aed;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>
|
||||
|
||||
## 貢献者
|
||||
|
||||
<a href="https://openomy.app/github/vbenjs/vue-vben-admin" target="_blank" style="display: block; width: 100%;" align="center">
|
||||
<img src="https://openomy.app/svg?repo=vbenjs/vue-vben-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
|
||||
<img alt="Contributors" src="https://contrib.rocks/image?repo=vbenjs/vue-vben-admin" />
|
||||
</a>
|
||||
|
||||
## Discord
|
||||
|
||||
- [Github Discussions](https://github.com/anncwb/vue-vben-admin/discussions)
|
||||
|
||||
## ライセンス
|
||||
|
||||
[MIT © Vben-2020](./LICENSE)
|
||||
@@ -0,0 +1,157 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/anncwb/vue-vben-admin">
|
||||
<img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp">
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
<h1>Vue Vben Admin</h1>
|
||||
</div>
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin) [](https://github.com/vbenjs/vue-vben-admin/actions/workflows/codeql.yml) [](https://github.com/vbenjs/vue-vben-admin/actions/workflows/build.yml) [](https://github.com/vbenjs/vue-vben-admin/actions/workflows/ci.yml) [](https://github.com/vbenjs/vue-vben-admin/actions/workflows/deploy.yml)
|
||||
|
||||
**English** | [中文](./README.zh-CN.md) | [日本語](./README.ja-JP.md)
|
||||
|
||||
## Introduction
|
||||
|
||||
Vue Vben Admin is a free and open source middle and back-end template. Using the latest `vue3`, `vite`, `TypeScript` and other mainstream technology development, the out-of-the-box middle and back-end front-end solutions can also be used for learning reference.
|
||||
|
||||
## Upgrade Notice
|
||||
|
||||
This is the latest version, 5.0, and it is not compatible with previous versions. If you are starting a new project, it is recommended to use the latest version. If you wish to view the old version, please use the [v2 branch](https://github.com/vbenjs/vue-vben-admin/tree/v2).
|
||||
|
||||
## Features
|
||||
|
||||
- **Latest Technology Stack**: Developed with cutting-edge front-end technologies like Vue 3 and Vite
|
||||
- **TypeScript**: A language for application-scale JavaScript
|
||||
- **Themes**: Multiple theme colors available with customizable options
|
||||
- **Internationalization**: Comprehensive built-in internationalization support
|
||||
- **Permissions**: Built-in solution for dynamic route-based permission generation
|
||||
|
||||
## Preview
|
||||
|
||||
- [Vben Admin](https://vben.pro/) - Full version Chinese site
|
||||
|
||||
Test Account: vben/123456
|
||||
|
||||
<div align="center">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview1.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview2.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview3.png">
|
||||
</div>
|
||||
|
||||
### Use Gitpod
|
||||
|
||||
Open the project in Gitpod (free online dev environment for GitHub) and start coding immediately.
|
||||
|
||||
[](https://gitpod.io/#https://github.com/vbenjs/vue-vben-admin)
|
||||
|
||||
## Documentation
|
||||
|
||||
[Document](https://doc.vben.pro/)
|
||||
|
||||
## Install and Use
|
||||
|
||||
1. Get the project code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vbenjs/vue-vben-admin.git
|
||||
```
|
||||
|
||||
2. Install dependencies
|
||||
|
||||
```bash
|
||||
cd vue-vben-admin
|
||||
npm i -g corepack
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Run
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Change Log
|
||||
|
||||
[CHANGELOG](https://github.com/vbenjs/vue-vben-admin/releases)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
You are very welcome to join! [Raise an issue](https://github.com/anncwb/vue-vben-admin/issues/new/choose) or submit a Pull Request.
|
||||
|
||||
**Pull Request Process:**
|
||||
|
||||
1. Fork the code
|
||||
2. Create your branch: `git checkout -b feat/xxxx`
|
||||
3. Submit your changes: `git commit -am 'feat(function): add xxxxx'`
|
||||
4. Push your branch: `git push origin feat/xxxx`
|
||||
5. Submit `pull request`
|
||||
|
||||
## Git Contribution Submission Specification
|
||||
|
||||
Reference [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) specification ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular))
|
||||
|
||||
- `feat` Add new features
|
||||
- `fix` Fix the problem/BUG
|
||||
- `style` The code style is related and does not affect the running result
|
||||
- `perf` Optimization/performance improvement
|
||||
- `refactor` Refactor
|
||||
- `revert` Undo edit
|
||||
- `test` Test related
|
||||
- `docs` Documentation/notes
|
||||
- `chore` Dependency update/scaffolding configuration modification etc.
|
||||
- `ci` Continuous integration
|
||||
- `types` Type definition file changes
|
||||
|
||||
## Browser Support
|
||||
|
||||
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
|
||||
|
||||
Support modern browsers, not IE
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| :-: | :-: | :-: | :-: |
|
||||
| last 2 versions | last 2 versions | last 2 versions | last 2 versions |
|
||||
|
||||
## Maintainer
|
||||
|
||||
[@Vben](https://github.com/anncwb)
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.dera.page/#vbenjs/vue-vben-admin&Date)
|
||||
|
||||
## Donate
|
||||
|
||||
If you think this project is helpful to you, you can help the author buy a cup of coffee to show your support!
|
||||
|
||||

|
||||
|
||||
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aee;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://openomy.app/github/vbenjs/vue-vben-admin" target="_blank" style="display: block; width: 100%;" align="center">
|
||||
<img src="https://openomy.app/svg?repo=vbenjs/vue-vben-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
|
||||
<img alt="Contributors" src="https://contrib.rocks/image?repo=vbenjs/vue-vben-admin" />
|
||||
</a>
|
||||
|
||||
## Discord
|
||||
|
||||
- [Github Discussions](https://github.com/anncwb/vue-vben-admin/discussions)
|
||||
|
||||
## License
|
||||
|
||||
[MIT © Vben-2020](./LICENSE)
|
||||
@@ -0,0 +1,157 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/anncwb/vue-vben-admin">
|
||||
<img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp">
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
<h1>Vue Vben Admin</h1>
|
||||
</div>
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin)    
|
||||
|
||||
**中文** | [English](./README.md) | [日本語](./README.ja-JP.md)
|
||||
|
||||
## 简介
|
||||
|
||||
Vue Vben Admin 是 Vue Vben Admin 的升级版本。作为一个免费开源的中后台模板,它采用了最新的 Vue 3、Vite、TypeScript 等主流技术开发,开箱即用,可用于中后台前端开发,也适合学习参考。
|
||||
|
||||
## 升级提示
|
||||
|
||||
该版本为最新版本 `5.0`,与其他版本不兼容,如果你是新项目,建议使用最新版本。如果你想查看旧版本,请使用 [v2 分支](https://github.com/vbenjs/vue-vben-admin/tree/v2)
|
||||
|
||||
## 特性
|
||||
|
||||
- **最新技术栈**:使用 Vue3/vite 等前端前沿技术开发
|
||||
- **TypeScript**:应用程序级 JavaScript 的语言
|
||||
- **主题**:提供多套主题色彩,可配置自定义主题
|
||||
- **国际化**:内置完善的国际化方案
|
||||
- **权限**:内置完善的动态路由权限生成方案
|
||||
|
||||
## 预览
|
||||
|
||||
- [Vben Admin](https://vben.pro/) - 完整版中文站点
|
||||
|
||||
测试账号:vben/123456
|
||||
|
||||
<div align="center">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview1.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview2.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview3.png">
|
||||
</div>
|
||||
|
||||
### 使用 Gitpod
|
||||
|
||||
在 Gitpod(适用于 GitHub 的免费在线开发环境)中打开项目,并立即开始编码。
|
||||
|
||||
[](https://gitpod.io/#https://github.com/vbenjs/vue-vben-admin)
|
||||
|
||||
## 文档
|
||||
|
||||
[文档地址](https://doc.vben.pro/)
|
||||
|
||||
## 安装使用
|
||||
|
||||
1. 获取项目代码
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vbenjs/vue-vben-admin.git
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
|
||||
```bash
|
||||
cd vue-vben-admin
|
||||
npm i -g corepack
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 运行
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. 打包
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
[CHANGELOG](https://github.com/vbenjs/vue-vben-admin/releases)
|
||||
|
||||
## 如何贡献
|
||||
|
||||
非常欢迎你的加入 或者提交一个 Pull Request。
|
||||
|
||||
**Pull Request 流程:**
|
||||
|
||||
1. Fork 代码
|
||||
2. 创建自己的分支:`git checkout -b feature/xxxx`
|
||||
3. 提交你的修改:`git commit -am 'feat(function): add xxxxx'`
|
||||
4. 推送您的分支:`git push origin feature/xxxx`
|
||||
5. 提交 `pull request`
|
||||
|
||||
## Git 贡献提交规范
|
||||
|
||||
参考 [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) 规范 ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular))
|
||||
|
||||
- `feat` 增加新功能
|
||||
- `fix` 修复问题/BUG
|
||||
- `style` 代码风格相关无影响运行结果的
|
||||
- `perf` 优化/性能提升
|
||||
- `refactor` 重构
|
||||
- `revert` 撤销修改
|
||||
- `test` 测试相关
|
||||
- `docs` 文档/注释
|
||||
- `chore` 依赖更新/脚手架配置修改等
|
||||
- `ci` 持续集成
|
||||
- `types` 类型定义文件更改
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
|
||||
|
||||
支持现代浏览器,不支持 IE
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| :-: | :-: | :-: | :-: |
|
||||
| last 2 versions | last 2 versions | last 2 versions | last 2 versions |
|
||||
|
||||
## 维护者
|
||||
|
||||
[@Vben](https://github.com/anncwb)
|
||||
|
||||
## Star 历史
|
||||
|
||||
[](https://star-history.dera.page/#vbenjs/vue-vben-admin&Date)
|
||||
|
||||
## 捐赠
|
||||
|
||||
如果你觉得这个项目对你有帮助,你可以帮作者买一杯咖啡表示支持!
|
||||
|
||||

|
||||
|
||||
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aed;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://openomy.app/github/vbenjs/vue-vben-admin" target="_blank" style="display: block; width: 100%;" align="center">
|
||||
<img src="https://openomy.app/svg?repo=vbenjs/vue-vben-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
|
||||
<img alt="Contributors" src="https://contrib.rocks/image?repo=vbenjs/vue-vben-admin" />
|
||||
</a>
|
||||
|
||||
## Discord
|
||||
|
||||
- [Github Discussions](https://github.com/anncwb/vue-vben-admin/discussions)
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT © Vben-2020](./LICENSE)
|
||||
@@ -0,0 +1,9 @@
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=真羊 AI 客服 · 管理后台
|
||||
|
||||
# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
|
||||
VITE_APP_NAMESPACE=zhenyang-admin
|
||||
|
||||
# 对 store 持久化到 localStorage 时加密用的密钥。
|
||||
# 模板里默认是 please-replace-me-with-your-own-key——那是公开值,等于没加密。
|
||||
VITE_APP_STORE_SECURE_KEY=DHIpryct4GsHrszDncvvxvzb8cp69mhEoXZvIU-ubik
|
||||
@@ -0,0 +1,7 @@
|
||||
# public path
|
||||
VITE_BASE=/
|
||||
|
||||
# Basic interface address SPA
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
VITE_VISUALIZER=true
|
||||
@@ -0,0 +1,16 @@
|
||||
# 端口号
|
||||
VITE_PORT=5666
|
||||
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
||||
VITE_NITRO_MOCK=false
|
||||
|
||||
# 是否打开 devtools,true 为打开,false 为关闭
|
||||
VITE_DEVTOOLS=false
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
@@ -0,0 +1,22 @@
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
# 生产走同源相对路径:前端是静态文件,由同一个 Nginx 把 /api 反代到
|
||||
# admin_api.py。模板默认值是 vben 的公共 mock 服务器 mock-napi.vben.pro——
|
||||
# 那意味着打包后所有管理请求(含登录凭据)都会发给第三方主机。
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||
VITE_COMPRESS=none
|
||||
|
||||
# 是否开启 PWA
|
||||
VITE_PWA=false
|
||||
|
||||
# vue-router 的模式
|
||||
VITE_ROUTER_HISTORY=hash
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta name="description" content="A Modern Back-end Management System" />
|
||||
<meta name="keywords" content="Vben Admin Vue3 Vite" />
|
||||
<meta name="author" content="Vben" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
||||
/>
|
||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script>
|
||||
// 生产环境下注入百度统计
|
||||
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
|
||||
var _hmt = _hmt || [];
|
||||
(function () {
|
||||
var hm = document.createElement('script');
|
||||
hm.src =
|
||||
'https://hm.baidu.com/hm.js?b38e689f40558f20a9a686d7f6f33edf';
|
||||
var s = document.getElementsByTagName('script')[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@vben/web-antd",
|
||||
"version": "5.7.0",
|
||||
"homepage": "https://vben.pro",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "apps/web-antd"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "vben",
|
||||
"email": "ann.vben@gmail.com",
|
||||
"url": "https://github.com/anncwb"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm vite build --mode production",
|
||||
"build:analyze": "pnpm vite build --mode analyze",
|
||||
"dev": "pnpm vite --mode development",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit --skipLibCheck"
|
||||
},
|
||||
"imports": {
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben/access": "workspace:*",
|
||||
"@vben/common-ui": "workspace:*",
|
||||
"@vben/constants": "workspace:*",
|
||||
"@vben/hooks": "workspace:*",
|
||||
"@vben/icons": "workspace:*",
|
||||
"@vben/layouts": "workspace:*",
|
||||
"@vben/locales": "workspace:*",
|
||||
"@vben/plugins": "workspace:*",
|
||||
"@vben/preferences": "workspace:*",
|
||||
"@vben/request": "workspace:*",
|
||||
"@vben/stores": "workspace:*",
|
||||
"@vben/styles": "workspace:*",
|
||||
"@vben/types": "workspace:*",
|
||||
"@vben/utils": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"ant-design-vue": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
|
||||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||
*/
|
||||
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type {
|
||||
AutoCompleteProps,
|
||||
ButtonProps,
|
||||
CascaderProps,
|
||||
CheckboxGroupProps,
|
||||
CheckboxProps,
|
||||
DatePickerProps,
|
||||
DividerProps,
|
||||
InputNumberProps,
|
||||
InputProps,
|
||||
MentionsProps,
|
||||
RadioGroupProps,
|
||||
RadioProps,
|
||||
RateProps,
|
||||
SelectProps,
|
||||
SpaceProps,
|
||||
SwitchProps,
|
||||
TextAreaProps,
|
||||
TimePickerProps,
|
||||
TreeSelectProps,
|
||||
UploadChangeParam,
|
||||
UploadFile,
|
||||
UploadProps,
|
||||
} from 'ant-design-vue';
|
||||
import type { RangePickerProps } from 'ant-design-vue/es/date-picker';
|
||||
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
ApiComponentSharedProps,
|
||||
BaseFormComponentType,
|
||||
IconPickerProps,
|
||||
} from '@vben/common-ui';
|
||||
import type { Sortable } from '@vben/hooks';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
render,
|
||||
unref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
ApiComponent,
|
||||
globalShareState,
|
||||
IconPicker,
|
||||
VCropper,
|
||||
} from '@vben/common-ui';
|
||||
import { useSortable } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { message, Modal, notification } from 'ant-design-vue';
|
||||
|
||||
type AdapterUploadProps = UploadProps & {
|
||||
aspectRatio?: string;
|
||||
crop?: boolean;
|
||||
draggable?: boolean;
|
||||
handleChange?: (event: UploadChangeParam) => void;
|
||||
maxSize?: number;
|
||||
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||
onHandleChange?: (event: UploadChangeParam) => void;
|
||||
};
|
||||
|
||||
const AutoComplete = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/auto-complete'),
|
||||
);
|
||||
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
|
||||
const Checkbox = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/checkbox'),
|
||||
);
|
||||
const CheckboxGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
|
||||
);
|
||||
const DatePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/date-picker'),
|
||||
);
|
||||
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
|
||||
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
|
||||
const InputNumber = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/input-number'),
|
||||
);
|
||||
const InputPassword = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.InputPassword),
|
||||
);
|
||||
const Mentions = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/mentions'),
|
||||
);
|
||||
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
|
||||
const RadioGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
|
||||
);
|
||||
const RangePicker = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
|
||||
);
|
||||
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
|
||||
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
|
||||
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
|
||||
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
|
||||
const Textarea = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.Textarea),
|
||||
);
|
||||
const TimePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/time-picker'),
|
||||
);
|
||||
const TreeSelect = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/tree-select'),
|
||||
);
|
||||
const Cascader = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/cascader'),
|
||||
);
|
||||
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
|
||||
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
|
||||
const PreviewGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
|
||||
);
|
||||
|
||||
const withDefaultPlaceholder = (
|
||||
component: Component,
|
||||
type: 'input' | 'select',
|
||||
componentProps: Recordable<any> = {},
|
||||
) => {
|
||||
return defineComponent({
|
||||
name: component.name,
|
||||
inheritAttrs: false,
|
||||
setup: (props: any, { attrs, expose, slots }) => {
|
||||
const placeholder =
|
||||
props?.placeholder ||
|
||||
attrs?.placeholder ||
|
||||
$t(`ui.placeholder.${type}`);
|
||||
// 透传组件暴露的方法
|
||||
const innerRef = ref();
|
||||
expose(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, key) => innerRef.value?.[key],
|
||||
has: (_target, key) => key in (innerRef.value || {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
return () =>
|
||||
h(
|
||||
component,
|
||||
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'svg',
|
||||
'webp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 检查是否为图片文件
|
||||
*/
|
||||
function isImageFile(file: UploadFile): boolean {
|
||||
if (file.url) {
|
||||
try {
|
||||
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
} catch {
|
||||
const ext = file.url?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
}
|
||||
if (!file.type) {
|
||||
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的上传按钮插槽
|
||||
*/
|
||||
function createDefaultUploadSlots(listType: string, placeholder: string) {
|
||||
if (listType === 'picture-card') {
|
||||
return { default: () => placeholder };
|
||||
}
|
||||
return {
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
icon: h(IconifyIcon, {
|
||||
icon: 'ant-design:upload-outlined',
|
||||
class: 'mb-1 size-4',
|
||||
}),
|
||||
},
|
||||
() => placeholder,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 Base64
|
||||
*/
|
||||
function getBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||
reader.addEventListener('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
*/
|
||||
async function previewImage(
|
||||
file: UploadFile,
|
||||
visible: Ref<boolean>,
|
||||
fileList: Ref<UploadProps['fileList']>,
|
||||
) {
|
||||
// 非图片文件直接打开链接
|
||||
if (!isImageFile(file)) {
|
||||
const url = file.url || file.preview;
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
message.error($t('ui.formRules.previewWarning'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
||||
Image,
|
||||
PreviewGroup,
|
||||
]);
|
||||
|
||||
// 过滤图片文件并生成预览
|
||||
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
|
||||
|
||||
for (const imgFile of imageFiles) {
|
||||
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
|
||||
imgFile.preview = await getBase64(imgFile.originFileObj);
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
let isUnmounted = false;
|
||||
|
||||
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
|
||||
|
||||
const PreviewWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
return h(
|
||||
PreviewGroupComponent,
|
||||
{
|
||||
class: 'hidden',
|
||||
preview: {
|
||||
visible: visible.value,
|
||||
current: currentIndex,
|
||||
onVisibleChange: (value: boolean) => {
|
||||
visible.value = value;
|
||||
if (!value) {
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
() =>
|
||||
imageFiles.map((imgFile) =>
|
||||
h(ImageComponent, {
|
||||
key: imgFile.uid,
|
||||
src: imgFile.url || imgFile.preview,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(PreviewWrapper), container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片裁剪操作
|
||||
*/
|
||||
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
let isUnmounted = false;
|
||||
let objectUrl: null | string = null;
|
||||
|
||||
const open = ref<boolean>(true);
|
||||
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
open.value = false;
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const CropperWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
if (!objectUrl) {
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
}
|
||||
return h(
|
||||
Modal,
|
||||
{
|
||||
open: open.value,
|
||||
title: h('div', {}, [
|
||||
$t('ui.crop.title'),
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||
},
|
||||
$t('ui.crop.titleTip', [aspectRatio]),
|
||||
),
|
||||
]),
|
||||
centered: true,
|
||||
width: 548,
|
||||
zIndex: 9999,
|
||||
keyboard: false,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
cancelText: $t('common.cancel'),
|
||||
okText: $t('ui.crop.confirm'),
|
||||
destroyOnClose: true,
|
||||
onOk: async () => {
|
||||
const cropper = cropperRef.value;
|
||||
if (!cropper) {
|
||||
reject(new Error('Cropper not found'));
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await cropper.getCropImage();
|
||||
if (dataUrl) {
|
||||
resolve(dataUrl);
|
||||
} else {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
}
|
||||
} catch {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
resolve('');
|
||||
closeModal();
|
||||
},
|
||||
},
|
||||
() =>
|
||||
h(VCropper, {
|
||||
ref: (ref: any) => (cropperRef.value = ref),
|
||||
img: objectUrl as string,
|
||||
aspectRatio,
|
||||
}),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(CropperWrapper), container);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 带预览功能的上传组件
|
||||
*/
|
||||
const withPreviewUpload = () => {
|
||||
return defineComponent({
|
||||
name: Upload.name,
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: any,
|
||||
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
||||
) {
|
||||
const previewVisible = ref<boolean>(false);
|
||||
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
|
||||
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
||||
const fileList = ref<UploadProps['fileList']>(
|
||||
attrs?.fileList || attrs?.['file-list'] || [],
|
||||
);
|
||||
|
||||
const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
|
||||
const aspectRatio = computed(
|
||||
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||
);
|
||||
|
||||
const handleBeforeUpload = async (
|
||||
file: UploadFile,
|
||||
originFileList: Array<File>,
|
||||
) => {
|
||||
// 文件大小限制
|
||||
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
||||
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
||||
file.status = 'removed';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 图片裁剪处理
|
||||
if (
|
||||
attrs.crop &&
|
||||
!attrs.multiple &&
|
||||
originFileList[0] &&
|
||||
isImageFile(file)
|
||||
) {
|
||||
file.status = 'removed';
|
||||
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
||||
if (!blob) {
|
||||
throw new Error($t('ui.crop.errorTip'));
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
return attrs.beforeUpload?.(file) ?? true;
|
||||
};
|
||||
|
||||
const handleChange = (event: UploadChangeParam) => {
|
||||
try {
|
||||
attrs.handleChange?.(event);
|
||||
attrs.onHandleChange?.(event);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
fileList.value = event.fileList.filter(
|
||||
(file) => file.status !== 'removed',
|
||||
);
|
||||
emit(
|
||||
'update:modelValue',
|
||||
event.fileList?.length ? fileList.value : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
previewVisible.value = true;
|
||||
await previewImage(file, previewVisible, fileList);
|
||||
};
|
||||
|
||||
const renderUploadButton = () => {
|
||||
if (attrs.disabled) return null;
|
||||
return isEmpty(slots)
|
||||
? createDefaultUploadSlots(listType, placeholder)
|
||||
: slots;
|
||||
};
|
||||
|
||||
// 拖拽排序
|
||||
const draggable = computed(
|
||||
() => (attrs.draggable ?? false) && !attrs.disabled,
|
||||
);
|
||||
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const sortableInstance = ref<null | Sortable>(null);
|
||||
|
||||
const styleId = `upload-drag-style-${uploadId}`;
|
||||
|
||||
function injectDragStyle() {
|
||||
if (!document.querySelector(`[id="${styleId}"]`)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
}
|
||||
|
||||
function removeDragStyle() {
|
||||
document.querySelector(`[id="${styleId}"]`)?.remove();
|
||||
}
|
||||
|
||||
async function initSortable(retryCount = 0) {
|
||||
if (!draggable.value) return;
|
||||
|
||||
injectDragStyle();
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const container = document.querySelector(
|
||||
`[data-upload-id="${uploadId}"] .ant-upload-list`,
|
||||
) as HTMLElement;
|
||||
|
||||
if (!container) {
|
||||
if (retryCount < 5) {
|
||||
setTimeout(() => initSortable(retryCount + 1), 200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { initializeSortable } = useSortable(container, {
|
||||
animation: 300,
|
||||
delay: 400,
|
||||
delayOnTouchOnly: true,
|
||||
filter:
|
||||
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (
|
||||
oldIndex === undefined ||
|
||||
newIndex === undefined ||
|
||||
oldIndex === newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = [...(fileList.value || [])];
|
||||
const [movedItem] = list.splice(oldIndex, 1);
|
||||
if (movedItem) {
|
||||
list.splice(newIndex, 0, movedItem);
|
||||
fileList.value = list;
|
||||
}
|
||||
|
||||
attrs.onDragSort?.(oldIndex, newIndex);
|
||||
emit('update:modelValue', fileList.value);
|
||||
},
|
||||
});
|
||||
|
||||
sortableInstance.value = await initializeSortable();
|
||||
}
|
||||
|
||||
// 监听表单值变化
|
||||
watch(
|
||||
() => attrs.modelValue,
|
||||
(res) => {
|
||||
fileList.value = res;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(initSortable);
|
||||
onUnmounted(() => {
|
||||
sortableInstance.value?.destroy();
|
||||
removeDragStyle();
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||
h(
|
||||
Upload,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
fileList: fileList.value,
|
||||
beforeUpload: handleBeforeUpload,
|
||||
onChange: handleChange,
|
||||
onPreview: handlePreview,
|
||||
},
|
||||
renderUploadButton() as any,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||
export type ComponentType =
|
||||
| 'ApiCascader'
|
||||
| 'ApiSelect'
|
||||
| 'ApiTreeSelect'
|
||||
| 'AutoComplete'
|
||||
| 'Cascader'
|
||||
| 'Checkbox'
|
||||
| 'CheckboxGroup'
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
| 'InputPassword'
|
||||
| 'Mentions'
|
||||
| 'PrimaryButton'
|
||||
| 'Radio'
|
||||
| 'RadioGroup'
|
||||
| 'RangePicker'
|
||||
| 'Rate'
|
||||
| 'Select'
|
||||
| 'Space'
|
||||
| 'Switch'
|
||||
| 'Textarea'
|
||||
| 'TimePicker'
|
||||
| 'TreeSelect'
|
||||
| 'Upload'
|
||||
| BaseFormComponentType;
|
||||
|
||||
/**
|
||||
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示
|
||||
*/
|
||||
export interface ComponentPropsMap {
|
||||
ApiCascader: ApiComponentSharedProps & CascaderProps;
|
||||
ApiSelect: ApiComponentSharedProps & SelectProps;
|
||||
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
|
||||
AutoComplete: AutoCompleteProps;
|
||||
Cascader: CascaderProps;
|
||||
Checkbox: CheckboxProps;
|
||||
CheckboxGroup: CheckboxGroupProps;
|
||||
DatePicker: DatePickerProps;
|
||||
DefaultButton: ButtonProps;
|
||||
Divider: DividerProps;
|
||||
IconPicker: IconPickerProps;
|
||||
Input: InputProps;
|
||||
InputNumber: InputNumberProps;
|
||||
InputPassword: InputProps;
|
||||
Mentions: MentionsProps;
|
||||
PrimaryButton: ButtonProps;
|
||||
Radio: RadioProps;
|
||||
RadioGroup: RadioGroupProps;
|
||||
RangePicker: RangePickerProps;
|
||||
Rate: RateProps;
|
||||
Select: SelectProps;
|
||||
Space: SpaceProps;
|
||||
Switch: SwitchProps;
|
||||
Textarea: TextAreaProps;
|
||||
TimePicker: TimePickerProps;
|
||||
TreeSelect: TreeSelectProps;
|
||||
Upload: AdapterUploadProps;
|
||||
}
|
||||
|
||||
async function initComponentAdapter() {
|
||||
const components: Partial<Record<ComponentType, Component>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
// Button: () =>
|
||||
// import('xxx').then((res) => res.Button),
|
||||
|
||||
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Cascader,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
AutoComplete,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
// 自定义默认按钮
|
||||
DefaultButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||
},
|
||||
Divider,
|
||||
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||
iconSlot: 'addonAfter',
|
||||
inputComponent: Input,
|
||||
modelValueProp: 'value',
|
||||
}),
|
||||
Input: withDefaultPlaceholder(Input, 'input'),
|
||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||
style: { width: '100%' },
|
||||
}),
|
||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||
// 自定义主要按钮
|
||||
PrimaryButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
||||
},
|
||||
Radio,
|
||||
RadioGroup,
|
||||
RangePicker,
|
||||
Rate,
|
||||
Select: withDefaultPlaceholder(Select, 'select'),
|
||||
Space,
|
||||
Switch,
|
||||
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
||||
TimePicker,
|
||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||
Upload: withPreviewUpload(),
|
||||
};
|
||||
|
||||
// 将组件注册到全局共享状态中
|
||||
globalShareState.setComponents(components);
|
||||
|
||||
// 定义全局共享状态中的消息提示
|
||||
globalShareState.defineMessage({
|
||||
// 复制成功消息提示
|
||||
copyPreferencesSuccess: (title, content) => {
|
||||
notification.success({
|
||||
description: content,
|
||||
message: title,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { initComponentAdapter };
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
VbenFormProps as FormProps,
|
||||
VbenFormSchema as FormSchema,
|
||||
FormValues,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
async function initSetupVbenForm() {
|
||||
setupVbenForm<ComponentType>({
|
||||
config: {
|
||||
// ant design vue组件库默认都是 v-model:value
|
||||
baseModelPropName: 'value',
|
||||
|
||||
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||
modelPropNameMap: {
|
||||
Checkbox: 'checked',
|
||||
Radio: 'checked',
|
||||
Switch: 'checked',
|
||||
Upload: 'fileList',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// 输入项目必填国际化适配
|
||||
required: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// 选择项目必填国际化适配
|
||||
selectRequired: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null) {
|
||||
return $t('ui.formRules.selectRequired', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useVbenForm<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
options: FormProps<
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>,
|
||||
) {
|
||||
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export { initSetupVbenForm, useVbenForm, z };
|
||||
|
||||
export type VbenFormSchema<TValues extends FormValues = FormValues> =
|
||||
FormSchema<ComponentType, ComponentPropsMap, TValues>;
|
||||
export type VbenFormProps<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { FormValues } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import {
|
||||
setupVbenVxeTable,
|
||||
useVbenVxeGrid as useGrid,
|
||||
} from '@vben/plugins/vxe-table';
|
||||
|
||||
import { Button, Image } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from './form';
|
||||
|
||||
setupVbenVxeTable({
|
||||
configVxeTable: (vxeUI) => {
|
||||
vxeUI.setConfig({
|
||||
grid: {
|
||||
align: 'center',
|
||||
border: false,
|
||||
columnConfig: {
|
||||
resizable: true,
|
||||
},
|
||||
minHeight: 180,
|
||||
formConfig: {
|
||||
// 全局禁用vxe-table的表单配置,使用formOptions
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
autoLoad: true,
|
||||
response: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
list: 'items',
|
||||
},
|
||||
showActiveMsg: true,
|
||||
showResponseMsg: false,
|
||||
},
|
||||
round: true,
|
||||
showOverflow: true,
|
||||
size: 'small',
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
// 表格配置项可以用 cellRender: { name: 'CellImage' },
|
||||
vxeUI.renderer.add('CellImage', {
|
||||
renderTableDefault(renderOpts, params) {
|
||||
const { props } = renderOpts;
|
||||
const { column, row } = params;
|
||||
return h(Image, { src: row[column.field], ...props });
|
||||
},
|
||||
});
|
||||
|
||||
// 表格配置项可以用 cellRender: { name: 'CellLink' },
|
||||
vxeUI.renderer.add('CellLink', {
|
||||
renderTableDefault(renderOpts) {
|
||||
const { props } = renderOpts;
|
||||
return h(
|
||||
Button,
|
||||
{ size: 'small', type: 'link' },
|
||||
{ default: () => props?.text },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
||||
// vxeUI.formats.add
|
||||
},
|
||||
useVbenForm,
|
||||
});
|
||||
|
||||
export const useVbenVxeGrid = <
|
||||
T extends Record<string, any>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
...rest: Parameters<
|
||||
typeof useGrid<
|
||||
T,
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>
|
||||
>
|
||||
) =>
|
||||
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
|
||||
...rest,
|
||||
);
|
||||
|
||||
export type * from '@vben/plugins/vxe-table';
|
||||
@@ -0,0 +1,354 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface ArchiveStats {
|
||||
conversations: number;
|
||||
exports: number;
|
||||
imports: number;
|
||||
last_message_at: string;
|
||||
media: number;
|
||||
media_failed: number;
|
||||
media_ready: number;
|
||||
messages: number;
|
||||
people: number;
|
||||
}
|
||||
|
||||
export interface ArchiveConversation {
|
||||
conversation_type: string;
|
||||
external_id: string;
|
||||
id: string;
|
||||
last_content: string;
|
||||
last_message_at: string;
|
||||
message_count: number;
|
||||
name: string;
|
||||
source_account: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMessage {
|
||||
attachment_count: number;
|
||||
attachments: ArchiveAttachment[];
|
||||
content: string;
|
||||
direction: string;
|
||||
id: string;
|
||||
message_type: string;
|
||||
sender_name: string;
|
||||
sender_person_id: null | string;
|
||||
sent_at: string;
|
||||
sequence_no: null | number;
|
||||
source_message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveAttachment {
|
||||
attachment_index: number;
|
||||
attachment_role: string;
|
||||
checksum?: string;
|
||||
id: string;
|
||||
match_confidence: number;
|
||||
match_method: string;
|
||||
media_type: 'audio' | 'file' | 'image' | 'video';
|
||||
mime_type: string;
|
||||
original_filename: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMediaAccess {
|
||||
expires_in: number;
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ArchiveStorage {
|
||||
bucket: string;
|
||||
custom_domain: string;
|
||||
enabled: boolean;
|
||||
encryption_mode: string;
|
||||
export_prefix: string;
|
||||
media_prefix: string;
|
||||
release_prefix: string;
|
||||
region: string;
|
||||
secret_id_masked: string;
|
||||
secret_id_present: boolean;
|
||||
secret_key_masked: string;
|
||||
secret_key_present: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMedia {
|
||||
bucket: string;
|
||||
created_at: string;
|
||||
encryption_mode: string;
|
||||
id: string;
|
||||
last_error: string;
|
||||
media_type: string;
|
||||
mime_type: string;
|
||||
object_key: string;
|
||||
original_filename: string;
|
||||
region: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
status: string;
|
||||
verified_at: string;
|
||||
version_id: string;
|
||||
}
|
||||
|
||||
export interface ArchivePerson {
|
||||
conversation_count: number;
|
||||
created_at: string;
|
||||
display_name: string;
|
||||
id: string;
|
||||
identity_count: number;
|
||||
message_count: number;
|
||||
patient_binding_count: number;
|
||||
primary_patient_id: null | number;
|
||||
primary_patient_name: string;
|
||||
real_name: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ArchiveIdentity {
|
||||
created_at: string;
|
||||
external_id: string;
|
||||
id: string;
|
||||
identity_type: string;
|
||||
scope_id: string;
|
||||
source: string;
|
||||
updated_at: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface ArchivePersonDetail {
|
||||
display_name: string;
|
||||
id: string;
|
||||
identities: ArchiveIdentity[];
|
||||
patient_bindings: PatientBinding[];
|
||||
real_name: string;
|
||||
}
|
||||
|
||||
export interface PatientBinding {
|
||||
bind_method: string;
|
||||
confidence: number;
|
||||
created_at: string;
|
||||
diagnosis_id: number;
|
||||
id: string;
|
||||
is_primary: boolean;
|
||||
patient_id: number;
|
||||
patient_name: string;
|
||||
phone_masked: string;
|
||||
relation_type: 'family' | 'guardian' | 'other' | 'proxy' | 'self';
|
||||
status: 'active' | 'revoked';
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ZytPatientCandidate {
|
||||
age: null | number;
|
||||
diagnosis_id: number;
|
||||
gender: number | string;
|
||||
patient_id: number;
|
||||
patient_name: string;
|
||||
phone_masked: string;
|
||||
source_update_time: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportFile {
|
||||
file_name: string;
|
||||
format: string;
|
||||
id: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
storage_status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportJob {
|
||||
completed_at: string;
|
||||
created_at: string;
|
||||
cutoff_at: string;
|
||||
error_message: string;
|
||||
files: ArchiveExportFile[];
|
||||
filters: Record<string, unknown>;
|
||||
formats: string[];
|
||||
id: string;
|
||||
progress: number;
|
||||
started_at: string;
|
||||
status: 'completed' | 'failed' | 'queued' | 'running';
|
||||
total_rows: number;
|
||||
}
|
||||
|
||||
function query(values: Record<string, number | string>) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value !== '') params.set(key, String(value));
|
||||
});
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : '';
|
||||
}
|
||||
|
||||
export function fetchArchiveStats(accountId = 0, signal?: AbortSignal) {
|
||||
return requestClient.get<ArchiveStats>(
|
||||
`/archive/stats${query({ account_id: accountId })}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveConversations(limit = 50, cursor = '', accountId = 0, signal?: AbortSignal) {
|
||||
return requestClient.get<{
|
||||
has_more: boolean;
|
||||
items: ArchiveConversation[];
|
||||
next_cursor: string;
|
||||
}>(`/archive/conversations${query({ account_id: accountId, cursor, limit })}`, { signal });
|
||||
}
|
||||
|
||||
export function fetchArchiveMessages(
|
||||
conversationId: string,
|
||||
limit = 100,
|
||||
cursor = '',
|
||||
accountId = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestClient.get<{
|
||||
has_more: boolean;
|
||||
items: ArchiveMessage[];
|
||||
next_cursor: string;
|
||||
}>(
|
||||
`/archive/conversations/${encodeURIComponent(conversationId)}/messages${query({ account_id: accountId, cursor, limit })}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveMediaAccessUrls(
|
||||
mediaIds: string[],
|
||||
expires = 900,
|
||||
accountId = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestClient.post<{ items: ArchiveMediaAccess[] }>(
|
||||
`/archive/media/access-urls${query({ account_id: accountId })}`,
|
||||
{ expires, media_ids: mediaIds },
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveStorage(signal?: AbortSignal) {
|
||||
return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage', { signal });
|
||||
}
|
||||
|
||||
export function saveArchiveStorage(data: Partial<ArchiveStorage> & {
|
||||
secret_id?: string;
|
||||
secret_key?: string;
|
||||
}) {
|
||||
return requestClient.put<{ storage: ArchiveStorage }>(
|
||||
'/archive/storage',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function testArchiveStorage() {
|
||||
return requestClient.post<{
|
||||
result: { bucket: string; ok: boolean; region: string; request_id: string };
|
||||
}>('/archive/storage/test');
|
||||
}
|
||||
|
||||
export function fetchArchiveMedia(limit = 100, accountId = 0, signal?: AbortSignal) {
|
||||
return requestClient.get<{ items: ArchiveMedia[] }>(
|
||||
`/archive/media${query({ account_id: accountId, limit })}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchivePeople(limit = 100, keyword = '', accountId = 0, signal?: AbortSignal) {
|
||||
return requestClient.get<{ items: ArchivePerson[] }>(
|
||||
`/archive/people${query({ account_id: accountId, keyword, limit })}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchivePerson(personId: string, accountId = 0) {
|
||||
return requestClient.get<{ person: ArchivePersonDetail }>(
|
||||
`/archive/people/${encodeURIComponent(personId)}${query({ account_id: accountId })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function bindArchiveIdentity(
|
||||
personId: string,
|
||||
data: {
|
||||
external_id: string;
|
||||
identity_type: string;
|
||||
scope_id: string;
|
||||
verified: boolean;
|
||||
},
|
||||
accountId = 0,
|
||||
) {
|
||||
return requestClient.post<{ person: ArchivePersonDetail }>(
|
||||
`/archive/people/${encodeURIComponent(personId)}/identities${query({ account_id: accountId })}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function searchZytPatients(
|
||||
keyword: string,
|
||||
accountId: number,
|
||||
pageNo = 1,
|
||||
pageSize = 20,
|
||||
) {
|
||||
return requestClient.get<{
|
||||
items: ZytPatientCandidate[];
|
||||
page_no: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
}>(
|
||||
`/archive/patients/search${query({ account_id: accountId, keyword, page_no: pageNo, page_size: pageSize })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function bindArchivePatient(
|
||||
personId: string,
|
||||
data: {
|
||||
diagnosis_id: number;
|
||||
patient_id: number;
|
||||
relation_type: PatientBinding['relation_type'];
|
||||
},
|
||||
accountId: number,
|
||||
) {
|
||||
return requestClient.put<{
|
||||
binding: PatientBinding;
|
||||
person: ArchivePersonDetail;
|
||||
}>(
|
||||
`/archive/people/${encodeURIComponent(personId)}/patient-bindings${query({ account_id: accountId })}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function unbindArchivePatient(
|
||||
personId: string,
|
||||
bindingId: string,
|
||||
accountId: number,
|
||||
) {
|
||||
return requestClient.delete<{ person: ArchivePersonDetail }>(
|
||||
`/archive/people/${encodeURIComponent(personId)}/patient-bindings/${encodeURIComponent(bindingId)}${query({ account_id: accountId })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createArchiveExport(data: {
|
||||
filters: Record<string, unknown>;
|
||||
formats: string[];
|
||||
}, accountId = 0) {
|
||||
return requestClient.post<{ job: ArchiveExportJob }>(
|
||||
`/archive/exports${query({ account_id: accountId })}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveExports(limit = 100, accountId = 0, signal?: AbortSignal) {
|
||||
return requestClient.get<{ jobs: ArchiveExportJob[] }>(
|
||||
`/archive/exports${query({ account_id: accountId, limit })}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadArchiveExport(fileId: string, accountId = 0) {
|
||||
return requestClient.download<Blob>(
|
||||
`/archive/export-files/${encodeURIComponent(fileId)}/download${query({ account_id: accountId })}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* 业务接口:模型清单、角色权限、用户、调用统计。
|
||||
*
|
||||
* 全部对 `admin_api.py` 的 /api/v2/*。这一层只负责搬数据,权限判定在后端——
|
||||
* 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 账号下拉框里「全部」的取值,和后端 `admin_api.ALL_ACCOUNTS` 是同一个约定。
|
||||
*
|
||||
* 用 -1 而不是 0:0 早就有含义了(旧版未归属数据),不传则是"按默认租户查"。
|
||||
* 这两个都被占着,再拿它们表示「全部」会让三种情况撞在一起。
|
||||
*/
|
||||
export const ALL_ACCOUNTS = -1;
|
||||
|
||||
export interface ModelProvider {
|
||||
api_key?: string;
|
||||
api_key_masked?: string;
|
||||
base_url: string;
|
||||
capabilities: string;
|
||||
enabled: boolean;
|
||||
/** 后端算好的实际请求地址(只读) */
|
||||
endpoint?: string;
|
||||
/** auto = 按接口类型补全路径;exact = 地址原样使用 */
|
||||
endpoint_mode: string;
|
||||
health: string;
|
||||
id: string;
|
||||
kind: string;
|
||||
max_inflight: number;
|
||||
max_tokens: number;
|
||||
model: string;
|
||||
name: string;
|
||||
rpm_limit: number;
|
||||
temperature: number;
|
||||
timeout_ms: number;
|
||||
}
|
||||
|
||||
export interface ModelPlan {
|
||||
answer_ids: string;
|
||||
fallback_ids: string;
|
||||
judge_id: string;
|
||||
judge_mode: string;
|
||||
version: number;
|
||||
vision_id: string;
|
||||
}
|
||||
|
||||
export interface RoleItem {
|
||||
builtin: boolean;
|
||||
code: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface PermissionItem {
|
||||
code: string;
|
||||
group_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DesktopAccountItem {
|
||||
active_session_count: number;
|
||||
app_version: string;
|
||||
avatar: string;
|
||||
backup_enabled: boolean | null;
|
||||
backup_interval_minutes: number | null;
|
||||
created_at: string;
|
||||
current_device_name: string;
|
||||
device_count: number;
|
||||
id: number;
|
||||
is_online: boolean;
|
||||
last_login_at: string;
|
||||
last_online_at: string;
|
||||
message_count: number;
|
||||
mobile_masked: string;
|
||||
nickname: string;
|
||||
source_account_count: number;
|
||||
status: 'active' | 'disabled';
|
||||
tenant_id: string;
|
||||
updated_at: string;
|
||||
zyt_sn: string;
|
||||
zyt_user_id: string;
|
||||
}
|
||||
|
||||
export interface CallStats {
|
||||
avg_ms: number;
|
||||
avg_score: number;
|
||||
chosen: { count: number; provider: string }[];
|
||||
judged: number;
|
||||
max_ms: number;
|
||||
risk: Record<string, number>;
|
||||
score_buckets: { count: number; range: string }[];
|
||||
since: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 单次调用的候选出口——`model_calls.candidates_json` 解出来的一项。 */
|
||||
export interface ModelCallCandidate {
|
||||
error?: string;
|
||||
latency_ms?: number;
|
||||
provider: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 单条调用留痕:客户说了什么、模型答了什么、有没有被拦下来。 */
|
||||
export interface ModelCallLogItem {
|
||||
candidates: ModelCallCandidate[];
|
||||
chosen: string;
|
||||
created_at: string;
|
||||
customer_text: string;
|
||||
device_id: string;
|
||||
id: number;
|
||||
judge_mode: string;
|
||||
judge_risk: string;
|
||||
judge_score: number;
|
||||
judge_winner: string;
|
||||
/** chat = 回客户的话;guard = 界面识别之类的内部判断 */
|
||||
purpose: string;
|
||||
reply_text: string;
|
||||
review_reason: string;
|
||||
roles_version: number;
|
||||
task_id: string;
|
||||
total_ms: number;
|
||||
}
|
||||
|
||||
// ── 模型 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchModels() {
|
||||
return requestClient.get<{
|
||||
judge_modes: string[];
|
||||
kinds: string[];
|
||||
models: ModelProvider[];
|
||||
roles: ModelPlan;
|
||||
}>('/models');
|
||||
}
|
||||
|
||||
export function saveModel(data: Partial<ModelProvider>) {
|
||||
return requestClient.post<{ models: ModelProvider[] }>('/models', data);
|
||||
}
|
||||
|
||||
export function deleteModel(id: string) {
|
||||
return requestClient.delete<{ models: ModelProvider[] }>(
|
||||
`/models/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveModelPlan(data: Partial<ModelPlan>) {
|
||||
return requestClient.post<{ roles: ModelPlan; version: number }>(
|
||||
'/models/plan',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 智能体 ───────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 一条角色规则。三种类型的必填项不一样:
|
||||
*
|
||||
* guide 要有 instruction。关键词留空 = 每轮都注入给模型。
|
||||
* forbid 要有关键词(回复里不许出现的词)。reply 留空则走客户端内置兜底。
|
||||
* reply 关键词和话术都要有,命中就直接用这句回,不问模型。
|
||||
*/
|
||||
export interface AgentRule {
|
||||
enabled: boolean;
|
||||
id?: string;
|
||||
instruction: string;
|
||||
keywords: string[];
|
||||
label: string;
|
||||
reply: string;
|
||||
type: 'forbid' | 'guide' | 'reply';
|
||||
}
|
||||
|
||||
export interface AgentItem {
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
/** 协作模式下认领本轮的关键词 */
|
||||
keywords: string[];
|
||||
name: string;
|
||||
persona: string;
|
||||
/** 数字越小越优先 */
|
||||
priority: number;
|
||||
role: string;
|
||||
rules: AgentRule[];
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AgentPlan {
|
||||
active_ids: string[];
|
||||
/** single = 只上一个角色;collaborate = 多个同时在场 */
|
||||
mode: 'collaborate' | 'single';
|
||||
primary_id: string;
|
||||
updated_at?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function fetchAgents() {
|
||||
return requestClient.get<{
|
||||
agents: AgentItem[];
|
||||
plan: AgentPlan;
|
||||
plan_modes: string[];
|
||||
rule_types: string[];
|
||||
}>('/agents');
|
||||
}
|
||||
|
||||
export function saveAgent(data: Partial<AgentItem>) {
|
||||
return requestClient.post<{ agents: AgentItem[]; plan: AgentPlan }>(
|
||||
'/agents',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAgent(id: string) {
|
||||
return requestClient.delete<{ agents: AgentItem[]; plan: AgentPlan }>(
|
||||
`/agents/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveAgentPlan(data: Partial<AgentPlan>) {
|
||||
return requestClient.post<{ plan: AgentPlan; version: number }>(
|
||||
'/agents/plan',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 角色与权限 ───────────────────────────────────────────────────────────────
|
||||
export function fetchRoles() {
|
||||
return requestClient.get<{ roles: RoleItem[] }>('/roles');
|
||||
}
|
||||
|
||||
export function fetchPermissions() {
|
||||
return requestClient.get<{ permissions: PermissionItem[] }>('/permissions');
|
||||
}
|
||||
|
||||
export function saveRole(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
}) {
|
||||
return requestClient.post<{ roles: RoleItem[] }>('/roles', data);
|
||||
}
|
||||
|
||||
export function deleteRole(code: string) {
|
||||
return requestClient.delete<{ roles: RoleItem[] }>(
|
||||
`/roles/${encodeURIComponent(code)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 用户 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchUsers() {
|
||||
return requestClient.get<{ users: UserItem[] }>('/users');
|
||||
}
|
||||
|
||||
export function createUser(data: {
|
||||
password: string;
|
||||
role: string;
|
||||
username: string;
|
||||
}) {
|
||||
return requestClient.post<{ users: UserItem[] }>('/users', data);
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
id: number,
|
||||
data: { active: boolean; role: string },
|
||||
) {
|
||||
return requestClient.put<{ users: UserItem[] }>(`/users/${id}`, data);
|
||||
}
|
||||
|
||||
// ── 客户端账号 ───────────────────────────────────────────────────────────────
|
||||
export function fetchDesktopAccounts(includeCounts = true) {
|
||||
return requestClient.get<{
|
||||
accounts: DesktopAccountItem[];
|
||||
can_view_all: boolean;
|
||||
can_view_legacy: boolean;
|
||||
}>(
|
||||
`/desktop-accounts?include_counts=${includeCounts}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDesktopAccount(
|
||||
id: number,
|
||||
data: {
|
||||
backup_enabled: boolean | null;
|
||||
backup_interval_minutes: number | null;
|
||||
status: 'active' | 'disabled';
|
||||
},
|
||||
) {
|
||||
return requestClient.put<{ account: DesktopAccountItem }>(
|
||||
`/desktop-accounts/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeDesktopDevices(id: number) {
|
||||
return requestClient.post(`/desktop-accounts/${id}/revoke-devices`);
|
||||
}
|
||||
|
||||
export function fetchDesktopAccountAdmins(id: number) {
|
||||
return requestClient.get<{ admin_user_ids: number[] }>(
|
||||
`/desktop-accounts/${id}/admins`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDesktopAccountAdmins(id: number, adminUserIds: number[]) {
|
||||
return requestClient.put<{ admin_user_ids: number[] }>(
|
||||
`/desktop-accounts/${id}/admins`,
|
||||
{ admin_user_ids: adminUserIds },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 运营 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchCallStats(days = 7, accountId = 0) {
|
||||
return requestClient.get<CallStats>(
|
||||
`/stats/model-calls?days=${days}&account_id=${accountId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCallLog(params: {
|
||||
accountId?: number;
|
||||
days?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** chat = 回客户的话(默认);guard = 界面识别;'' = 全都要 */
|
||||
purpose?: string;
|
||||
q?: string;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
query.set('days', String(params.days ?? 7));
|
||||
query.set('account_id', String(params.accountId ?? 0));
|
||||
query.set('limit', String(params.limit ?? 50));
|
||||
query.set('offset', String(params.offset ?? 0));
|
||||
query.set('purpose', params.purpose ?? 'chat');
|
||||
if (params.q) query.set('q', params.q);
|
||||
return requestClient.get<{ items: ModelCallLogItem[]; total: number }>(
|
||||
`/stats/model-calls/log?${query.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAudit(limit = 200) {
|
||||
return requestClient.get<{
|
||||
entries: {
|
||||
action: string;
|
||||
created_at: string;
|
||||
detail: string;
|
||||
id: number;
|
||||
ip_address: string;
|
||||
username: null | string;
|
||||
}[];
|
||||
}>(`/audit?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function changeMyPassword(data: {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}) {
|
||||
return requestClient.post('/me/password', data);
|
||||
}
|
||||
|
||||
// ── 桌面端配置 ───────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 下发给桌面客户端的配置。
|
||||
*
|
||||
* 字段名是大写下划线,和老网页后台的表单完全一致——两边共用后端同一个校验函数,
|
||||
* 不会出现"这边填得进、那边填不进"。
|
||||
*
|
||||
* 这里**没有模型连接参数**:服务类型、API 地址、密钥、模型名、温度、tokens、
|
||||
* 超时全部搬到了「AI 模型 → 模型清单 / 角色编排」。桌面端的模型调用走网关,
|
||||
* 密钥不出后端。
|
||||
*/
|
||||
export interface DesktopConfig {
|
||||
ARCHIVE_AUTO_BACKUP_ENABLED: boolean;
|
||||
ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES: number;
|
||||
AI_AGENT_NAME: string;
|
||||
AI_CONTEXT_ENABLED: boolean;
|
||||
AI_CONTEXT_MAX_ROUNDS: number;
|
||||
AI_COUNTER_INSULT_ENABLED: boolean;
|
||||
AI_DEVELOPMENT_MODE: boolean;
|
||||
AI_ENABLED: boolean;
|
||||
AI_HOSPITAL_NAME: string;
|
||||
AI_MCP_ENABLED: boolean;
|
||||
AI_MCP_MAX_ROUNDS: number;
|
||||
AI_MCP_SERVERS: unknown[];
|
||||
AI_UI_GUARD_ENABLED: boolean;
|
||||
AI_USE_VISION: boolean;
|
||||
/** 留空 = 按后台自己的地址自动推算 */
|
||||
AI_GATEWAY_URL: string;
|
||||
/** 桌面客户端启动后从管理端读取的 ZYT 登录 API 基础地址 */
|
||||
ZYT_LOGIN_API_URL: string;
|
||||
AI_REVIEW_RULES: ReviewRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择性审核规则:命中任意一条,这一条回复才会停下来等人工确认,其余照常
|
||||
* 自动发送。`id` 可以留空——留空由后端根据 label 自动生成并去重。
|
||||
*/
|
||||
export interface ReviewRule {
|
||||
id?: string;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigEnvelope {
|
||||
bool_keys: string[];
|
||||
config: DesktopConfig;
|
||||
/** 留空时实际会下发给客户端的网关地址 */
|
||||
gateway_url_effective: string;
|
||||
/** 登录窗口启动时实际会从公开配置拿到的地址 */
|
||||
login_api_url_effective: string;
|
||||
/** 模型设置搬去哪儿了,用来在页面上给人指路 */
|
||||
model_settings_moved_to: string;
|
||||
retired_keys: string[];
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function fetchConfig() {
|
||||
return requestClient.get<ConfigEnvelope>('/config');
|
||||
}
|
||||
|
||||
export function saveConfig(data: DesktopConfig) {
|
||||
return requestClient.post<{ version: number }>('/config', data);
|
||||
}
|
||||
|
||||
// ── 桌面端版本升级 ───────────────────────────────────────────────────────────
|
||||
export interface ReleasePolicy {
|
||||
auto_install: boolean;
|
||||
download_url: string;
|
||||
force_upgrade: boolean;
|
||||
latest_version: string;
|
||||
package_sha256: string;
|
||||
package_size: number;
|
||||
release_channel: 'beta' | 'stable';
|
||||
release_notes: string;
|
||||
rollout_percent: number;
|
||||
signature_required: boolean;
|
||||
status: 'paused' | 'published';
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
export interface ReleasePackage {
|
||||
download_url: string;
|
||||
file_name: string;
|
||||
package_sha256: string;
|
||||
package_size: number;
|
||||
}
|
||||
|
||||
interface ReleaseUploadPart {
|
||||
etag?: string;
|
||||
part_number: number;
|
||||
size_bytes: number;
|
||||
upload_url: string;
|
||||
}
|
||||
|
||||
interface ReleaseUploadPrepared extends ReleasePackage {
|
||||
media: { id: string };
|
||||
multipart?: {
|
||||
completed_parts: ReleaseUploadPart[];
|
||||
part_size: number;
|
||||
parts: ReleaseUploadPart[];
|
||||
upload_id: string;
|
||||
};
|
||||
required_headers: Record<string, string>;
|
||||
reused: boolean;
|
||||
upload_mode: 'multipart' | 'reused' | 'single';
|
||||
upload_url: string;
|
||||
}
|
||||
|
||||
export interface UpdateReportItem {
|
||||
current_version: string;
|
||||
device_id: string;
|
||||
error: string;
|
||||
nickname: string;
|
||||
progress: number;
|
||||
status: string;
|
||||
target_version: string;
|
||||
updated_at: string;
|
||||
zyt_sn: string;
|
||||
}
|
||||
|
||||
export function fetchRelease() {
|
||||
return requestClient.get<ReleasePolicy>('/release');
|
||||
}
|
||||
|
||||
export function saveRelease(data: {
|
||||
auto_install: boolean;
|
||||
download_url: string;
|
||||
force_upgrade: boolean;
|
||||
latest_version: string;
|
||||
package_sha256: string;
|
||||
package_size: number;
|
||||
release_channel: 'beta' | 'stable';
|
||||
release_notes: string;
|
||||
rollout_percent: number;
|
||||
signature_required: boolean;
|
||||
status: 'paused' | 'published';
|
||||
}) {
|
||||
return requestClient.post<ReleasePolicy>('/release', data);
|
||||
}
|
||||
|
||||
async function sha256File(file: File) {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error('当前浏览器不支持安全哈希,请使用 HTTPS 或本机地址打开管理端');
|
||||
}
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
await file.arrayBuffer(),
|
||||
);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function putCosBlob(
|
||||
url: string,
|
||||
body: Blob,
|
||||
headers: Record<string, string>,
|
||||
onProgress?: (loaded: number) => void,
|
||||
) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url, true);
|
||||
xhr.timeout = 10 * 60 * 1000;
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.upload.onprogress = (event) => onProgress?.(event.loaded);
|
||||
xhr.onerror = () => reject(new Error('无法连接腾讯 COS,请检查网络和 Bucket CORS 配置'));
|
||||
xhr.ontimeout = () => reject(new Error('上传腾讯 COS 超时,请重试'));
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve((xhr.getResponseHeader('ETag') || '').replaceAll('"', ''));
|
||||
return;
|
||||
}
|
||||
reject(new Error(`腾讯 COS 上传失败(HTTP ${xhr.status})`));
|
||||
};
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadMultipartPackage(
|
||||
file: File,
|
||||
prepared: ReleaseUploadPrepared,
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
const multipart = prepared.multipart;
|
||||
if (!multipart?.upload_id || !multipart.parts.length) {
|
||||
throw new Error('COS 分块上传参数不完整');
|
||||
}
|
||||
const partSize = multipart.part_size;
|
||||
const uploaded = new Map<number, number>();
|
||||
const completed = new Map<number, string>();
|
||||
multipart.completed_parts.forEach((part) => {
|
||||
if (part.etag) {
|
||||
completed.set(part.part_number, part.etag);
|
||||
uploaded.set(part.part_number, part.size_bytes);
|
||||
}
|
||||
});
|
||||
const reportProgress = () => {
|
||||
const bytes = [...uploaded.values()].reduce((sum, value) => sum + value, 0);
|
||||
onProgress?.(5 + Math.round((bytes / file.size) * 90));
|
||||
};
|
||||
reportProgress();
|
||||
const pending = multipart.parts.filter(
|
||||
(part) => !completed.has(part.part_number),
|
||||
);
|
||||
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const part = pending.shift();
|
||||
if (!part) return;
|
||||
const start = (part.part_number - 1) * partSize;
|
||||
const blob = file.slice(start, start + part.size_bytes);
|
||||
let etag = '';
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
uploaded.set(part.part_number, 0);
|
||||
try {
|
||||
etag = await putCosBlob(part.upload_url, blob, {}, (loaded) => {
|
||||
uploaded.set(part.part_number, loaded);
|
||||
reportProgress();
|
||||
});
|
||||
lastError = undefined;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
if (!etag) {
|
||||
throw new Error('COS 未返回 ETag,请在 Bucket CORS 中暴露 ETag 响应头');
|
||||
}
|
||||
uploaded.set(part.part_number, part.size_bytes);
|
||||
completed.set(part.part_number, etag);
|
||||
reportProgress();
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(4, Math.max(1, pending.length)) }, worker),
|
||||
);
|
||||
return requestClient.post<ReleasePackage>(
|
||||
`/release/package/${encodeURIComponent(prepared.media.id)}/multipart-complete`,
|
||||
{
|
||||
parts: [...completed.entries()]
|
||||
.map(([part_number, etag]) => ({ etag, part_number }))
|
||||
.sort((left, right) => left.part_number - right.part_number),
|
||||
upload_id: multipart.upload_id,
|
||||
},
|
||||
{ timeout: 5 * 60 * 1000 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadReleasePackage(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
if (!file.name.toLowerCase().endsWith('.exe')) {
|
||||
throw new Error('升级包必须是 Windows EXE 安装包');
|
||||
}
|
||||
if (file.size <= 0 || file.size > 1024 ** 3) {
|
||||
throw new Error('安装包大小必须大于 0 且不能超过 1GB');
|
||||
}
|
||||
onProgress?.(1);
|
||||
const packageSha256 = await sha256File(file);
|
||||
onProgress?.(5);
|
||||
const prepared = await requestClient.post<ReleaseUploadPrepared>(
|
||||
'/release/package/prepare',
|
||||
{
|
||||
file_name: file.name,
|
||||
package_sha256: packageSha256,
|
||||
package_size: file.size,
|
||||
},
|
||||
);
|
||||
if (prepared.reused) {
|
||||
onProgress?.(100);
|
||||
return prepared;
|
||||
}
|
||||
if (prepared.upload_mode === 'multipart') {
|
||||
const result = await uploadMultipartPackage(file, prepared, onProgress);
|
||||
onProgress?.(100);
|
||||
return result;
|
||||
}
|
||||
if (!prepared.upload_url) throw new Error('COS 预上传接口没有返回上传地址');
|
||||
await putCosBlob(
|
||||
prepared.upload_url,
|
||||
file,
|
||||
prepared.required_headers,
|
||||
(loaded) => onProgress?.(5 + Math.round((loaded / file.size) * 90)),
|
||||
);
|
||||
onProgress?.(96);
|
||||
const result = await requestClient.post<ReleasePackage>(
|
||||
`/release/package/${encodeURIComponent(prepared.media.id)}/complete`,
|
||||
undefined,
|
||||
{ timeout: 5 * 60 * 1000 },
|
||||
);
|
||||
onProgress?.(100);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function fetchReleaseHistory() {
|
||||
return requestClient.get<{
|
||||
history: ReleasePolicy[];
|
||||
reports: {
|
||||
counts: Record<string, number>;
|
||||
items: UpdateReportItem[];
|
||||
};
|
||||
}>('/releases');
|
||||
}
|
||||
|
||||
// ── 模型连通性测试 ───────────────────────────────────────────────────────────
|
||||
export interface ModelTestResult {
|
||||
endpoint: string;
|
||||
http_status: null | number;
|
||||
latency_ms: number;
|
||||
message: string;
|
||||
model: string;
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给了 provider_id 就测清单里那一条,密钥由后端从库里解密取用——
|
||||
* 前端从头到尾拿不到明文,也就不可能在网络上多走一趟。
|
||||
*/
|
||||
export function testModel(data: {
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
endpoint_mode?: string;
|
||||
kind?: string;
|
||||
model?: string;
|
||||
provider_id?: string;
|
||||
timeout_seconds?: number;
|
||||
}) {
|
||||
return requestClient.post<{ label: string; result: ModelTestResult }>(
|
||||
'/models/test',
|
||||
data,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 认证接口。
|
||||
*
|
||||
* vben 默认对接的是它自带的 mock 后端(`/auth/login` 返回 `{accessToken}`、
|
||||
* `/auth/codes` 单独取权限码)。我们的后端是 `admin_api.py`,形状不一样,而且
|
||||
* **登录时就把权限码一起返回了**——少一次往返,也避免"已登录但权限还没到"
|
||||
* 这个中间态导致路由守卫误判。
|
||||
*
|
||||
* 适配放在这一层,不去改后端:接口契约不该被某一个客户端的习惯带偏。
|
||||
*/
|
||||
export namespace AuthApi {
|
||||
export interface LoginParams {
|
||||
password?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/** 后端 /api/v2/auth/login 的真实返回 */
|
||||
export interface BackendLoginResult {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
user: {
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
permissions: string[];
|
||||
role: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录后暂存的权限码。getAccessCodesApi 直接用它,省掉一次请求。 */
|
||||
let cachedCodes: string[] = [];
|
||||
|
||||
export function takeCachedAccessCodes(): string[] {
|
||||
return cachedCodes;
|
||||
}
|
||||
|
||||
export async function loginApi(data: AuthApi.LoginParams) {
|
||||
const resp = await requestClient.post<AuthApi.BackendLoginResult>(
|
||||
'/auth/login',
|
||||
{ username: data.username, password: data.password, device_name: 'web' },
|
||||
);
|
||||
cachedCodes = resp.user?.permissions ?? [];
|
||||
return { accessToken: resp.access_token } as AuthApi.LoginResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 accessToken。
|
||||
*
|
||||
* 后端目前发的是 30 天有效的长令牌,没有 refresh token 机制,所以这里必须明确
|
||||
* 失败——返回一个假 token 会让请求拦截器以为续期成功,接着用一个无效令牌无限
|
||||
* 重试,表现是页面卡死而不是跳登录页。
|
||||
*/
|
||||
export async function refreshTokenApi(): Promise<never> {
|
||||
throw new Error('后端未启用令牌续期,请重新登录');
|
||||
}
|
||||
|
||||
export async function logoutApi() {
|
||||
cachedCodes = [];
|
||||
return requestClient.post('/auth/logout', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前用户的权限码。
|
||||
*
|
||||
* 优先用登录时带回来的那份;刷新页面后缓存是空的,再去 /me 拿一次。
|
||||
*/
|
||||
export async function getAccessCodesApi(): Promise<string[]> {
|
||||
if (cachedCodes.length > 0) {
|
||||
return cachedCodes;
|
||||
}
|
||||
const me = await requestClient.get<{ permissions: string[] }>('/me');
|
||||
cachedCodes = me.permissions ?? [];
|
||||
return cachedCodes;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './menu';
|
||||
export * from './user';
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordStringComponent } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取用户所有菜单
|
||||
*/
|
||||
export async function getAllMenusApi() {
|
||||
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { UserInfo } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
interface BackendMe {
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
permissions: string[];
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前登录用户。
|
||||
*
|
||||
* 后端的 `/api/v2/me` 是权限的唯一数据源——路由守卫和按钮显隐都以它为准。
|
||||
* 这里把它映射成 vben 的 UserInfo 形状。
|
||||
*
|
||||
* `roles` 塞的是**权限码**而不是角色名,这是有意的:vben 的 `v-access:role`
|
||||
* 指令按这个数组匹配,而我们全线只判权限码,不判角色名。判角色名等于把运营
|
||||
* 策略焊死在前端,加个角色就要重新发版。
|
||||
*/
|
||||
export async function getUserInfoApi(): Promise<UserInfo> {
|
||||
const me = await requestClient.get<BackendMe>('/me');
|
||||
return {
|
||||
avatar: '',
|
||||
realName: me.username,
|
||||
roles: me.permissions ?? [],
|
||||
userId: String(me.id),
|
||||
username: me.username,
|
||||
// 首登必须改密:把落地页锁死在改密页,用户改完才放行。后端不会拦改密接口
|
||||
// 本身(拦了就只能去老网页后台改),锁在前端这一层。
|
||||
homePath: me.must_change_password ? '/profile/password' : undefined,
|
||||
desc: me.role,
|
||||
token: '',
|
||||
} as UserInfo;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { requestErrorMessage, responseErrorMessage } from './error-message';
|
||||
|
||||
describe('API error messages', () => {
|
||||
it('formats FastAPI query validation arrays as readable text without request input', () => {
|
||||
const data = { detail: [{ type: 'string_too_short', loc: ['body', 'query'], msg: 'String should have at least 2 characters', input: 'PRIVATE', ctx: { min_length: 2 } }] };
|
||||
expect(responseErrorMessage(data)).toBe('检索问题至少需要 2 个字符');
|
||||
expect(requestErrorMessage({ response: { data } })).toBe('检索问题至少需要 2 个字符');
|
||||
});
|
||||
it('handles response bodies unwrapped by RequestClient', () => {
|
||||
expect(requestErrorMessage({ detail: [{ type: 'string_too_short', loc: ['body', 'query'], ctx: { min_length: 2 } }] })).toBe('检索问题至少需要 2 个字符');
|
||||
expect(requestErrorMessage({ detail: '知识索引暂不可用' })).toBe('知识索引暂不可用');
|
||||
});
|
||||
it('handles maximum length and missing or wrong-type input', () => {
|
||||
expect(responseErrorMessage({ detail: [{ type: 'string_too_long', loc: ['body', 'query'], ctx: { max_length: 2000 } }] })).toBe('检索问题不能超过 2000 个字符');
|
||||
expect(responseErrorMessage({ detail: [{ type: 'missing', loc: ['body', 'query'] }] })).toBe('请填写检索问题');
|
||||
expect(responseErrorMessage({ detail: [{ type: 'string_type', loc: ['body', 'query'] }] })).toBe('检索问题必须是文本');
|
||||
});
|
||||
it('preserves existing business errors and supports nested structured messages', () => {
|
||||
expect(responseErrorMessage({ detail: '请先选择具体账号' })).toBe('请先选择具体账号');
|
||||
expect(responseErrorMessage({ error: { message: '服务暂不可用' } })).toBe('服务暂不可用');
|
||||
expect(responseErrorMessage({ message: '没有操作权限' })).toBe('没有操作权限');
|
||||
});
|
||||
it('uses safe fallback for unknown objects, empty payload and network failures', () => {
|
||||
for (const value of [null, undefined, {}, { detail: { input: 'PRIVATE' } }, { detail: [] }]) {
|
||||
expect(responseErrorMessage(value, '请重试')).toBe('请重试');
|
||||
}
|
||||
expect(requestErrorMessage(new Error('network'), '网络异常')).toBe('网络异常');
|
||||
});
|
||||
it('does not stringify recursive or unknown objects', () => {
|
||||
const circular: Record<string, unknown> = {}; circular.detail = circular;
|
||||
expect(responseErrorMessage(circular)).toBe('请求失败,请稍后重试');
|
||||
expect(responseErrorMessage({ detail: [{ type: 'custom_error', loc: ['body', 'title'], msg: '值无效', input: 'PRIVATE' }] })).toBe('请求参数:值无效');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Convert API errors to text without rendering objects or echoing request inputs. */
|
||||
function errorText(value: unknown, depth = 0): string {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (!value || typeof value !== 'object' || depth > 3) return '';
|
||||
if (Array.isArray(value)) {
|
||||
return [...new Set(value.slice(0, 3).map((entry) => errorText(entry, depth + 1)).filter(Boolean))].join(';');
|
||||
}
|
||||
const detail = value as Record<string, unknown>;
|
||||
if (Array.isArray(detail.loc) && typeof detail.type === 'string') {
|
||||
const field = detail.loc.at(-1) === 'query' ? '检索问题' : '请求参数';
|
||||
const ctx = detail.ctx as Record<string, unknown> | undefined;
|
||||
if (detail.type === 'string_too_short' && typeof ctx?.min_length === 'number') return `${field}至少需要 ${ctx.min_length} 个字符`;
|
||||
if (detail.type === 'string_too_long' && typeof ctx?.max_length === 'number') return `${field}不能超过 ${ctx.max_length} 个字符`;
|
||||
if (detail.type === 'missing') return `请填写${field}`;
|
||||
if (detail.type === 'string_type') return `${field}必须是文本`;
|
||||
return typeof detail.msg === 'string' ? `${field}:${detail.msg}` : `${field}格式不正确`;
|
||||
}
|
||||
for (const key of ['detail', 'error', 'message', 'msg']) {
|
||||
const text = errorText(detail[key], depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function responseErrorMessage(data: unknown, fallback = '请求失败,请稍后重试'): string {
|
||||
return errorText(data) || fallback;
|
||||
}
|
||||
|
||||
export function requestErrorMessage(error: unknown, fallback = '请求失败,请稍后重试'): string {
|
||||
const data = (error as { response?: { data?: unknown } } | null)?.response?.data;
|
||||
// RequestClient unwraps Axios response.data before rejecting to page handlers.
|
||||
return responseErrorMessage(data ?? (error instanceof Error ? undefined : error), fallback);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core';
|
||||
@@ -0,0 +1,136 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface KnowledgeItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
conditions: string;
|
||||
category: string;
|
||||
kind: 'case' | 'procedure' | 'qa';
|
||||
status: string;
|
||||
revision: number;
|
||||
valid_until: string;
|
||||
flags: string[];
|
||||
updated_at: string;
|
||||
vector_status: string;
|
||||
sources?: { content: string; message_id: string; role: string; sent_at: string; version_no: number }[];
|
||||
history?: { action: string; actor_id: number; created_at: string; revision: number }[];
|
||||
}
|
||||
|
||||
export interface KnowledgeModel { id: string; name: string; model: string; kind: string; default: boolean }
|
||||
|
||||
export interface KnowledgeJob {
|
||||
id: string;
|
||||
status: string;
|
||||
processed: number;
|
||||
created_items: number;
|
||||
duplicates: number;
|
||||
skipped: number;
|
||||
model_calls: number;
|
||||
input_chars: number;
|
||||
output_chars: number;
|
||||
error: string;
|
||||
created_at: string;
|
||||
total_messages: null | number;
|
||||
progress_percent: null | number;
|
||||
completion_reason: string;
|
||||
pending_items: number;
|
||||
options: { engine: string; max_model_calls: number; model_provider_id?: string; model_name?: string; model_timeout_seconds?: number; model_format_fallbacks?: number; model_last_warning?: string; max_messages: number; source_name?: string; incremental?: boolean };
|
||||
}
|
||||
|
||||
export interface KnowledgePreview {
|
||||
available_messages: number;
|
||||
eligible_text_messages: number;
|
||||
conversations: number;
|
||||
total_messages: number;
|
||||
limited: boolean;
|
||||
cutoff_at: string;
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeOverview {
|
||||
counts: Record<string, number>;
|
||||
settings: null | { enabled: boolean; updated_at: string };
|
||||
semantic_configured: boolean;
|
||||
watches: { source_account_id: string; enabled: boolean }[];
|
||||
source_accounts: { display_name: string; external_account_id: string; id: string }[];
|
||||
}
|
||||
|
||||
export interface KnowledgeSearch {
|
||||
hits: (KnowledgeItem & { score: number })[];
|
||||
mode: string;
|
||||
elapsed_ms: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeTrace {
|
||||
id: string;
|
||||
task_id: string;
|
||||
query: string;
|
||||
hits: { id: string; revision: number; score: number }[];
|
||||
mode: string;
|
||||
elapsed_ms: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function path(resource: string, accountId: number, extra: Record<string, number | string> = {}) {
|
||||
return `/knowledge/${resource}?${new URLSearchParams(Object.entries({ account_id: accountId, ...extra }).map(([k, v]) => [k, String(v)]))}`;
|
||||
}
|
||||
|
||||
export const fetchKnowledgeOverview = (id: number, signal?: AbortSignal) =>
|
||||
requestClient.get<KnowledgeOverview>(path('overview', id), { signal });
|
||||
export const fetchKnowledgeJobs = (id: number, signal?: AbortSignal) =>
|
||||
requestClient.get<{ items: KnowledgeJob[] }>(path('jobs', id), { signal });
|
||||
export const fetchKnowledgeItems = (id: number, status: string, query: string, page: number, signal?: AbortSignal) =>
|
||||
requestClient.get<{ items: KnowledgeItem[]; total: number }>(path('items', id, { page, query, status }), { signal });
|
||||
export const fetchKnowledgeItem = (id: number, item: string) =>
|
||||
requestClient.get<KnowledgeItem>(path(`items/${item}`, id));
|
||||
export const saveKnowledgeItem = (id: number, item: KnowledgeItem) =>
|
||||
requestClient.put<KnowledgeItem>(path(`items/${item.id}`, id), item);
|
||||
export const knowledgeItemAction = (id: number, item: KnowledgeItem, action: string, confirmed = false, restoreRevision = 0) =>
|
||||
requestClient.post<KnowledgeItem>(path(`items/${item.id}/${action}`, id), { confirmed, restore_revision: restoreRevision, revision: item.revision });
|
||||
export const fetchKnowledgeModels = (id: number) =>
|
||||
requestClient.get<{ items: KnowledgeModel[] }>(path('models', id));
|
||||
export const configureKnowledgeJob = (id: number, job: string, body: Record<string, unknown>) =>
|
||||
requestClient.put<KnowledgeJob>(path(`jobs/${job}/config`, id), body);
|
||||
export const createKnowledgeJob = (id: number, body: Record<string, unknown>) =>
|
||||
requestClient.post<KnowledgeJob>(path('jobs', id), body);
|
||||
export const knowledgeJobAction = (id: number, job: string, action: string) =>
|
||||
requestClient.post<KnowledgeJob>(path(`jobs/${job}/${action}`, id));
|
||||
export const searchKnowledge = (id: number, query: string) =>
|
||||
requestClient.post<KnowledgeSearch>(path('search', id), { query });
|
||||
export const setKnowledgeEnabled = (id: number, enabled: boolean) =>
|
||||
requestClient.put(path('settings', id), { enabled });
|
||||
export const fetchKnowledgeTraces = (id: number, signal?: AbortSignal) =>
|
||||
requestClient.get<{ items: KnowledgeTrace[] }>(path('retrievals', id), { signal });
|
||||
export const exportKnowledge = (id: number) =>
|
||||
requestClient.get<Blob>(path('export', id), { responseType: 'blob', responseReturn: 'body' });
|
||||
|
||||
export const setKnowledgeWatch = (id: number, sourceId: string, enabled: boolean) =>
|
||||
requestClient.put(path(`watch/${sourceId}`, id), { enabled });
|
||||
|
||||
export const previewKnowledgeJob = (id: number, body: Record<string, unknown>) =>
|
||||
requestClient.post<KnowledgePreview>(path('jobs/preview', id), body, { timeout: 60000 });
|
||||
|
||||
|
||||
export interface KnowledgeReviewJob {
|
||||
id: string; account_id: number; scope_label: string; status: string; total: number; processed: number;
|
||||
approved: number; skipped: number; error: string; created_at: string; progress_percent: number;
|
||||
preview: { total: number; eligible: number; blocked: number; snapshot_at: string;
|
||||
reasons: { code: string; reason: string; count: number }[];
|
||||
accounts: { tenant_id: string; name: string; total: number; eligible: number }[] };
|
||||
}
|
||||
export interface KnowledgeReviewResult {
|
||||
item_id: string; tenant_id: string; revision: number; result: string; reason: string; reason_text: string;
|
||||
title: string; account_id: number; account_name: string;
|
||||
}
|
||||
export const previewKnowledgeReview = (accountId: number) =>
|
||||
requestClient.post<KnowledgeReviewJob>(path('review-tasks/preview', accountId), {}, { timeout: 120000 });
|
||||
export const fetchKnowledgeReviewJobs = (signal?: AbortSignal) =>
|
||||
requestClient.get<{ items: KnowledgeReviewJob[] }>('/knowledge/review-tasks', { signal });
|
||||
export const knowledgeReviewAction = (jobId: string, action: string, confirmed = false) =>
|
||||
requestClient.post<KnowledgeReviewJob>(`/knowledge/review-tasks/${jobId}/${action}`, { confirmed });
|
||||
export const fetchKnowledgeReviewResults = (jobId: string, result: string, page: number) =>
|
||||
requestClient.get<{ items: KnowledgeReviewResult[]; total: number }>(`/knowledge/review-tasks/${jobId}/results`, { params: { result, page } });
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 该文件可自行根据业务逻辑进行调整
|
||||
*/
|
||||
import type { RequestClientOptions } from '@vben/request';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import {
|
||||
authenticateResponseInterceptor,
|
||||
defaultResponseInterceptor,
|
||||
errorMessageResponseInterceptor,
|
||||
RequestClient,
|
||||
} from '@vben/request';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { refreshTokenApi } from './core';
|
||||
import { responseErrorMessage } from './error-message';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
...options,
|
||||
baseURL,
|
||||
});
|
||||
|
||||
/**
|
||||
* 重新认证逻辑。
|
||||
*
|
||||
* 这里必须防重入:登出本身也是一个带鉴权头的请求,令牌已经无效时它同样会
|
||||
* 拿到 401,于是又走进这个函数、又去登出……递归下去,表现是浏览器疯狂
|
||||
* 发 POST /auth/logout 直到页面卡死。authStore.logout() 里的 try/catch 挡不住,
|
||||
* 它catch 的是自己那一层,下一层照样新起一轮。
|
||||
*/
|
||||
let reAuthenticating = false;
|
||||
async function doReAuthenticate() {
|
||||
if (reAuthenticating) {
|
||||
return;
|
||||
}
|
||||
reAuthenticating = true;
|
||||
console.warn('Access token or refresh token is invalid or expired. ');
|
||||
const accessStore = useAccessStore();
|
||||
const authStore = useAuthStore();
|
||||
accessStore.setAccessToken(null);
|
||||
if (
|
||||
preferences.app.loginExpiredMode === 'modal' &&
|
||||
accessStore.isAccessChecked
|
||||
) {
|
||||
accessStore.setLoginExpired(true);
|
||||
} else {
|
||||
try {
|
||||
await authStore.logout();
|
||||
} finally {
|
||||
reAuthenticating = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
reAuthenticating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token 逻辑。
|
||||
*
|
||||
* 后端发的是 30 天有效的长令牌,没有 refresh token。配套地
|
||||
* `enableRefreshToken` 关掉了,拦截器不会走到这里;万一哪天有人打开开关,
|
||||
* 这里立刻抛错,比默默续期一个假令牌然后无限重试要好得多。
|
||||
*/
|
||||
async function doRefreshToken(): Promise<string> {
|
||||
await refreshTokenApi();
|
||||
throw new Error('后端未启用令牌续期');
|
||||
}
|
||||
|
||||
function formatToken(token: null | string) {
|
||||
return token ? `Bearer ${token}` : null;
|
||||
}
|
||||
|
||||
// 请求头处理
|
||||
client.addRequestInterceptor({
|
||||
fulfilled: async (config) => {
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
config.headers.Authorization = formatToken(accessStore.accessToken);
|
||||
config.headers['Accept-Language'] = preferences.app.locale;
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
// 处理返回的响应数据格式。
|
||||
//
|
||||
// 模板默认配的是 `{codeField:'code', dataField:'data', successCode:0}`,也就是
|
||||
// 假定后端返回 `{code:0, data:{...}}` 这种信封。admin_api.py 返回的是扁平
|
||||
// JSON(`{"access_token":...}`),没有 code 字段,按默认配置每一个 200 都会被
|
||||
// 判成失败并抛出——症状是点登录毫无反应,控制台还一句错都不报。
|
||||
//
|
||||
// 所以改成透传:成败由 HTTP 状态码表达(这本来就是它该干的事),
|
||||
// 整个响应体就是数据。这个拦截器不能直接删掉——删了之后没人拆包,
|
||||
// 拿到的是整个 axios response,`resp.access_token` 一样是 undefined。
|
||||
client.addResponseInterceptor(
|
||||
defaultResponseInterceptor({
|
||||
codeField: '',
|
||||
dataField: (response) => response,
|
||||
successCode: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
// token过期的处理
|
||||
client.addResponseInterceptor(
|
||||
authenticateResponseInterceptor({
|
||||
client,
|
||||
doReAuthenticate,
|
||||
doRefreshToken,
|
||||
enableRefreshToken: preferences.app.enableRefreshToken,
|
||||
formatToken,
|
||||
}),
|
||||
);
|
||||
|
||||
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
|
||||
client.addResponseInterceptor(
|
||||
errorMessageResponseInterceptor((msg: string, error) => {
|
||||
// FastAPI detail may be a validation array or object, never pass it directly to a toast.
|
||||
message.error(responseErrorMessage(error?.response?.data, msg));
|
||||
}),
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export const requestClient = createRequestClient(apiURL, {
|
||||
responseReturn: 'data',
|
||||
});
|
||||
|
||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { tokens } = useAntdDesignTokens();
|
||||
|
||||
const tokenTheme = computed(() => {
|
||||
const algorithm = isDark.value
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
token: tokens,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
|
||||
<App>
|
||||
<RouterView />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createApp, watchEffect } from 'vue';
|
||||
|
||||
import { registerAccessDirective } from '@vben/access';
|
||||
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { initStores } from '@vben/stores';
|
||||
import '@vben/styles';
|
||||
import '@vben/styles/antd';
|
||||
|
||||
import { useTitle } from '@vueuse/core';
|
||||
|
||||
import { $t, setupI18n } from '#/locales';
|
||||
|
||||
import { initComponentAdapter } from './adapter/component';
|
||||
import { initSetupVbenForm } from './adapter/form';
|
||||
import App from './app.vue';
|
||||
import { router } from './router';
|
||||
|
||||
async function bootstrap(namespace: string) {
|
||||
// 初始化组件适配器
|
||||
await initComponentAdapter();
|
||||
|
||||
// 初始化表单组件
|
||||
await initSetupVbenForm();
|
||||
|
||||
// // 设置弹窗的默认配置
|
||||
// setDefaultModalProps({
|
||||
// fullscreenButton: false,
|
||||
// });
|
||||
// // 设置抽屉的默认配置
|
||||
// setDefaultDrawerProps({
|
||||
// zIndex: 1020,
|
||||
// });
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// 注册v-loading指令
|
||||
registerLoadingDirective(app, {
|
||||
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
|
||||
spinning: 'spinning',
|
||||
});
|
||||
|
||||
// 国际化 i18n 配置
|
||||
await setupI18n(app);
|
||||
|
||||
// 配置 pinia-tore
|
||||
await initStores(app, { namespace });
|
||||
|
||||
// 安装权限指令
|
||||
registerAccessDirective(app);
|
||||
|
||||
// 初始化 tippy
|
||||
const { initTippy } = await import('@vben/common-ui/es/tippy');
|
||||
initTippy(app);
|
||||
|
||||
// 配置路由及路由守卫
|
||||
app.use(router);
|
||||
|
||||
// 配置Motion插件
|
||||
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||
app.use(MotionPlugin);
|
||||
|
||||
// 动态更新标题
|
||||
watchEffect(() => {
|
||||
if (preferences.app.dynamicTitle) {
|
||||
const routeTitle = router.currentRoute.value.meta?.title;
|
||||
const pageTitle =
|
||||
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
|
||||
useTitle(pageTitle);
|
||||
}
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
export { bootstrap };
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { DesktopAccountItem } from '#/api/console';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Select } from 'ant-design-vue';
|
||||
|
||||
import { ALL_ACCOUNTS, fetchDesktopAccounts } from '#/api/console';
|
||||
|
||||
const props = defineProps<{ modelValue: number; disabled?: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
change: [value: number];
|
||||
'update:modelValue': [value: number];
|
||||
}>();
|
||||
const accounts = ref<DesktopAccountItem[]>([]);
|
||||
const canViewLegacy = ref(false);
|
||||
const loading = ref(false);
|
||||
const dropdownOpen = ref(false);
|
||||
defineExpose({ open: () => { dropdownOpen.value = true; } });
|
||||
|
||||
// 换了 key(原来是 wecom-archive-account-id)。不换的话,所有老浏览器里都
|
||||
// 存着一个具体账号,升级之后第一眼看到的还是"只有某一个人"——而这次改动的
|
||||
// 全部意义就是默认看全部。换个 key 等于所有人从「全部」重新开始,之后各自的
|
||||
// 选择照旧记住。
|
||||
const STORAGE_KEY = 'wecom-archive-account-scope';
|
||||
|
||||
function choose(value: number) {
|
||||
dropdownOpen.value = false;
|
||||
localStorage.setItem(STORAGE_KEY, String(value));
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await fetchDesktopAccounts(false);
|
||||
accounts.value = response.accounts;
|
||||
canViewLegacy.value = response.can_view_legacy;
|
||||
const saved = Number(localStorage.getItem(STORAGE_KEY) ?? ALL_ACCOUNTS);
|
||||
// 记住的账号可能已经被停用或移出授权范围,这时退回「全部」,而不是把人
|
||||
// 卡在一个查不出数据的账号上。
|
||||
const value =
|
||||
saved === ALL_ACCOUNTS || (saved === 0 && canViewLegacy.value) || accounts.value.some((item) => item.id === saved)
|
||||
? saved
|
||||
: ALL_ACCOUNTS;
|
||||
if (value !== props.modelValue) emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select
|
||||
:value="modelValue"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:open="dropdownOpen"
|
||||
@dropdown-visible-change="(visible) => { dropdownOpen = visible; }"
|
||||
class="min-w-[240px]"
|
||||
@change="(value) => choose(Number(value))"
|
||||
>
|
||||
<Select.Option :value="ALL_ACCOUNTS">全部账号</Select.Option>
|
||||
<Select.Option v-if="canViewLegacy" :value="0">旧版未归属数据</Select.Option>
|
||||
<Select.Option v-for="item in accounts" :key="item.id" :value="item.id">
|
||||
{{ item.nickname || item.zyt_sn }}({{ item.zyt_sn }})
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthPageLayout } from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const appName = computed(() => preferences.app.name);
|
||||
const logo = computed(() => preferences.logo.source);
|
||||
const logoDark = computed(() => preferences.logo.sourceDark);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPageLayout
|
||||
:app-name="appName"
|
||||
:logo="logo"
|
||||
:logo-dark="logoDark"
|
||||
:page-description="$t('authentication.pageDesc')"
|
||||
:page-title="$t('authentication.pageTitle')"
|
||||
>
|
||||
<!-- 自定义工具栏 -->
|
||||
<!-- <template #toolbar></template> -->
|
||||
</AuthPageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
Notification,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
|
||||
const notifications = ref<NotificationItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
|
||||
date: '3小时前',
|
||||
isRead: true,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '收到了 14 份新周报',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '刚刚',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '朱偏右 回复了你',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '2024-01-01',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '曲丽丽 评论了你',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '代办提醒',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转Workspace示例',
|
||||
link: '/workspace',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转外部链接示例',
|
||||
link: 'https://doc.vben.pro',
|
||||
},
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const { isDark } = usePreferences();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
router.push({ name: 'Profile' });
|
||||
},
|
||||
icon: 'lucide:user',
|
||||
text: $t('page.auth.profile'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_DOC_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: BookOpenText,
|
||||
text: $t('ui.widgets.document'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_GITHUB_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: SvgGithubIcon,
|
||||
text: 'GitHub',
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(`${VBEN_GITHUB_URL}/issues`, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: CircleHelp,
|
||||
text: $t('ui.widgets.qa'),
|
||||
},
|
||||
]);
|
||||
|
||||
const avatar = computed(() => {
|
||||
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout(false);
|
||||
}
|
||||
|
||||
function handleNoticeClear() {
|
||||
notifications.value = [];
|
||||
}
|
||||
|
||||
function markRead(id: number | string) {
|
||||
const item = notifications.value.find((item) => item.id === id);
|
||||
if (item) {
|
||||
item.isRead = true;
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: number | string) {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
}
|
||||
|
||||
const viewAll = () => {};
|
||||
|
||||
const handleClick = (item: NotificationItem) => {
|
||||
// 如果通知项有链接,点击时跳转
|
||||
if (item.link) {
|
||||
navigateTo(item.link, item.query, item.state);
|
||||
}
|
||||
};
|
||||
|
||||
function navigateTo(
|
||||
link: string,
|
||||
query?: Record<string, any>,
|
||||
state?: Record<string, any>,
|
||||
) {
|
||||
if (link.startsWith('http://') || link.startsWith('https://')) {
|
||||
// 外部链接,在新标签页打开
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
// 内部路由链接,支持 query 参数和 state
|
||||
router.push({
|
||||
path: link,
|
||||
query: query || {},
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
content: preferences.app.watermarkContent,
|
||||
isDark: isDark.value,
|
||||
}),
|
||||
async ({ enable, content, isDark: isDarkValue }) => {
|
||||
if (enable) {
|
||||
const watermarkColor = isDarkValue
|
||||
? 'rgba(255, 255, 255, 0.12)'
|
||||
: 'rgba(0, 0, 0, 0.12)';
|
||||
|
||||
await updateWatermark({
|
||||
advancedStyle: {
|
||||
colorStops: [
|
||||
{
|
||||
color: watermarkColor,
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
color: watermarkColor,
|
||||
offset: 1,
|
||||
},
|
||||
],
|
||||
type: 'linear',
|
||||
},
|
||||
content:
|
||||
content ||
|
||||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
|
||||
});
|
||||
} else {
|
||||
destroyWatermark();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicLayout
|
||||
:avatar
|
||||
:text="userStore.userInfo?.realName"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
>
|
||||
<template #user-dropdown>
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:menus
|
||||
:text="userStore.userInfo?.realName"
|
||||
description="ann.vben@gmail.com"
|
||||
tag-text="Pro"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
:dot="showDot"
|
||||
:notifications="notifications"
|
||||
@clear="handleNoticeClear"
|
||||
@read="(item) => item.id && markRead(item.id)"
|
||||
@remove="(item) => item.id && remove(item.id)"
|
||||
@make-all="handleMakeAll"
|
||||
@on-click="handleClick"
|
||||
@view-all="viewAll"
|
||||
/>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
:avatar
|
||||
>
|
||||
<LoginForm />
|
||||
</AuthenticationLoginExpiredModal>
|
||||
</template>
|
||||
<template #lock-screen>
|
||||
<LockScreen :avatar @to-login="handleLogout" />
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
const BasicLayout = () => import('./basic.vue');
|
||||
const AuthPageLayout = () => import('./auth.vue');
|
||||
|
||||
const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
|
||||
|
||||
export { AuthPageLayout, BasicLayout, IFrameView };
|
||||
@@ -0,0 +1,3 @@
|
||||
# locale
|
||||
|
||||
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Locale } from 'ant-design-vue/es/locale';
|
||||
|
||||
import type { App } from 'vue';
|
||||
|
||||
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
$t,
|
||||
setupI18n as coreSetup,
|
||||
loadLocalesMapFromDir,
|
||||
} from '@vben/locales';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import antdEnLocale from 'ant-design-vue/es/locale/en_US';
|
||||
import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const antdLocale = ref<Locale>(antdDefaultLocale);
|
||||
|
||||
const modules = import.meta.glob('./langs/**/*.json');
|
||||
|
||||
const localesMap = loadLocalesMapFromDir(
|
||||
/\.\/langs\/([^/]+)\/(.*)\.json$/,
|
||||
modules,
|
||||
);
|
||||
/**
|
||||
* 加载应用特有的语言包
|
||||
* 这里也可以改造为从服务端获取翻译数据
|
||||
* @param lang
|
||||
*/
|
||||
async function loadMessages(lang: SupportedLanguagesType) {
|
||||
const [appLocaleMessages] = await Promise.all([
|
||||
localesMap[lang]?.(),
|
||||
loadThirdPartyMessage(lang),
|
||||
]);
|
||||
return appLocaleMessages?.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载第三方组件库的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
|
||||
await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载dayjs的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadDayjsLocale(lang: SupportedLanguagesType) {
|
||||
let locale;
|
||||
switch (lang) {
|
||||
case 'en-US': {
|
||||
locale = await import('dayjs/locale/en');
|
||||
break;
|
||||
}
|
||||
case 'zh-CN': {
|
||||
locale = await import('dayjs/locale/zh-cn');
|
||||
break;
|
||||
}
|
||||
// 默认使用英语
|
||||
default: {
|
||||
locale = await import('dayjs/locale/en');
|
||||
}
|
||||
}
|
||||
if (locale) {
|
||||
dayjs.locale(locale);
|
||||
} else {
|
||||
console.error(`Failed to load dayjs locale for ${lang}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载antd的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadAntdLocale(lang: SupportedLanguagesType) {
|
||||
switch (lang) {
|
||||
case 'en-US': {
|
||||
antdLocale.value = antdEnLocale;
|
||||
break;
|
||||
}
|
||||
case 'zh-CN': {
|
||||
antdLocale.value = antdDefaultLocale;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
|
||||
await coreSetup(app, {
|
||||
defaultLocale: preferences.app.locale,
|
||||
loadMessages,
|
||||
missingWarn: !import.meta.env.PROD,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export { $t, antdLocale, setupI18n };
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "Demos",
|
||||
"antd": "Ant Design Vue",
|
||||
"vben": {
|
||||
"title": "Project",
|
||||
"about": "About",
|
||||
"document": "Document",
|
||||
"antdv": "Ant Design Vue Version",
|
||||
"antdv-next": "Antdv Next Version",
|
||||
"naive-ui": "Naive UI Version",
|
||||
"element-plus": "Element Plus Version",
|
||||
"tdesign": "TDesign Vue Version"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"codeLogin": "Code Login",
|
||||
"qrcodeLogin": "Qr Code Login",
|
||||
"forgetPassword": "Forget Password",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"analytics": "Analytics",
|
||||
"workspace": "Workspace"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "演示",
|
||||
"antd": "Ant Design Vue",
|
||||
"vben": {
|
||||
"title": "项目",
|
||||
"about": "关于",
|
||||
"document": "文档",
|
||||
"antdv": "Ant Design Vue 版本",
|
||||
"antdv-next": "Antdv Next 版本",
|
||||
"naive-ui": "Naive UI 版本",
|
||||
"element-plus": "Element Plus 版本",
|
||||
"tdesign": "TDesign Vue 版本"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"codeLogin": "验证码登录",
|
||||
"qrcodeLogin": "二维码登录",
|
||||
"forgetPassword": "忘记密码",
|
||||
"profile": "个人中心"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
"analytics": "分析页",
|
||||
"workspace": "工作台"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { initPreferences } from '@vben/preferences';
|
||||
import { unmountGlobalLoading } from '@vben/utils';
|
||||
|
||||
import { overridesPreferences, preferencesExtension } from './preferences';
|
||||
|
||||
/**
|
||||
* 应用初始化完成之后再进行页面加载渲染
|
||||
*/
|
||||
async function initApplication() {
|
||||
// name用于指定项目唯一标识
|
||||
// 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
|
||||
const env = import.meta.env.PROD ? 'prod' : 'dev';
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION;
|
||||
const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
|
||||
|
||||
// app偏好设置初始化
|
||||
await initPreferences({
|
||||
extension: preferencesExtension,
|
||||
namespace,
|
||||
overrides: overridesPreferences,
|
||||
});
|
||||
|
||||
// 启动应用并挂载
|
||||
// vue应用主要逻辑及视图
|
||||
const { bootstrap } = await import('./bootstrap');
|
||||
await bootstrap(namespace);
|
||||
|
||||
// 移除并销毁loading
|
||||
unmountGlobalLoading();
|
||||
}
|
||||
|
||||
initApplication();
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
defineOverridesPreferences,
|
||||
definePreferencesExtension,
|
||||
} from '@vben/preferences';
|
||||
|
||||
interface WebAntdPreferencesExtension {
|
||||
defaultTableSize: number;
|
||||
enableFormFullscreen: boolean;
|
||||
reportTitle: string;
|
||||
tenantMode: 'multi' | 'single';
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 项目配置文件
|
||||
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
|
||||
* !!! 更改配置后请清空缓存,否则可能不生效
|
||||
*/
|
||||
export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
app: {
|
||||
name: import.meta.env.VITE_APP_TITLE,
|
||||
// 内部管理后台,不需要主题色/布局那一堆面向 C 端的偏好设置开关
|
||||
enablePreferences: false,
|
||||
// 模板默认首页是 /dashboard/analytics——那是一整页 Math.random() 生成的
|
||||
// 假图表。已经删掉了,首页改成调用统计:这套系统真正的总览就是它。
|
||||
defaultHomePath: '/model/stats',
|
||||
// 后端发的是长期令牌,没有 refresh token 机制。开着这个开关,401 之后
|
||||
// 拦截器会先去尝试续期、失败再登出,白白多一轮请求和一次报错。
|
||||
enableRefreshToken: false,
|
||||
},
|
||||
copyright: {
|
||||
companyName: '真羊',
|
||||
companySiteLink: '',
|
||||
date: '2026',
|
||||
enable: true,
|
||||
// 模板默认挂的是 vben 作者名下的真实备案号(闽ICP备19024351号)。
|
||||
// 那是别人的备案,挂在这里既不对也没意义。等这套后台真的对公网提供服务、
|
||||
// 拿到自己的备案号,再填进来;内网部署本来就不需要备案。
|
||||
icp: '',
|
||||
icpLink: '',
|
||||
},
|
||||
});
|
||||
|
||||
export const preferencesExtension =
|
||||
definePreferencesExtension<WebAntdPreferencesExtension>({
|
||||
tabLabel: 'preferences.antd.tabLabel',
|
||||
title: 'preferences.antd.title',
|
||||
fields: [
|
||||
{
|
||||
component: 'switch',
|
||||
defaultValue: true,
|
||||
key: 'enableFormFullscreen',
|
||||
label: 'preferences.antd.fields.enableFormFullscreen.label',
|
||||
tip: 'preferences.antd.fields.enableFormFullscreen.tip',
|
||||
},
|
||||
{
|
||||
component: 'select',
|
||||
defaultValue: 'single',
|
||||
key: 'tenantMode',
|
||||
label: 'preferences.antd.fields.tenantMode.label',
|
||||
options: [
|
||||
{
|
||||
label: 'preferences.antd.fields.tenantMode.options.single.label',
|
||||
value: 'single',
|
||||
},
|
||||
{
|
||||
label: 'preferences.antd.fields.tenantMode.options.multi.label',
|
||||
value: 'multi',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 200,
|
||||
min: 10,
|
||||
step: 10,
|
||||
},
|
||||
defaultValue: 20,
|
||||
key: 'defaultTableSize',
|
||||
label: 'preferences.antd.fields.defaultTableSize.label',
|
||||
},
|
||||
{
|
||||
component: 'input',
|
||||
defaultValue: '',
|
||||
key: 'reportTitle',
|
||||
label: 'preferences.antd.fields.reportTitle.label',
|
||||
placeholder: 'preferences.antd.fields.reportTitle.placeholder',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
ComponentRecordType,
|
||||
GenerateMenuAndRoutesOptions,
|
||||
} from '@vben/types';
|
||||
|
||||
import { generateAccessible } from '@vben/access';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { getAllMenusApi } from '#/api';
|
||||
import { BasicLayout, IFrameView } from '#/layouts';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
|
||||
|
||||
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
||||
|
||||
const layoutMap: ComponentRecordType = {
|
||||
BasicLayout,
|
||||
IFrameView,
|
||||
};
|
||||
|
||||
return await generateAccessible(preferences.app.accessMode, {
|
||||
...options,
|
||||
fetchMenuListAsync: async () => {
|
||||
message.loading({
|
||||
content: `${$t('common.loadingMenu')}...`,
|
||||
duration: 1.5,
|
||||
});
|
||||
return await getAllMenusApi();
|
||||
},
|
||||
// 可以指定没有权限跳转403页面
|
||||
forbiddenComponent,
|
||||
// 如果 route.meta.menuVisibleWithForbidden = true
|
||||
layoutMap,
|
||||
pageMap,
|
||||
});
|
||||
}
|
||||
|
||||
export { generateAccess };
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { startProgress, stopProgress } from '@vben/utils';
|
||||
|
||||
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { generateAccess } from './access';
|
||||
|
||||
/**
|
||||
* 通用守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function setupCommonGuard(router: Router) {
|
||||
// 记录已经加载的页面
|
||||
const loadedPaths = new Set<string>();
|
||||
|
||||
router.beforeEach((to) => {
|
||||
to.meta.loaded = loadedPaths.has(to.path);
|
||||
|
||||
// 页面加载进度条
|
||||
if (!to.meta.loaded && preferences.transition.progress) {
|
||||
startProgress();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
|
||||
|
||||
loadedPaths.add(to.path);
|
||||
|
||||
// 关闭页面加载进度条
|
||||
if (preferences.transition.progress) {
|
||||
stopProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限访问守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function setupAccessGuard(router: Router) {
|
||||
router.beforeEach(async (to, from) => {
|
||||
const accessStore = useAccessStore();
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 基本路由,这些路由不需要进入权限拦截
|
||||
if (coreRouteNames.includes(to.name as string)) {
|
||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||
return decodeURIComponent(
|
||||
(to.query?.redirect as string) ||
|
||||
userStore.userInfo?.homePath ||
|
||||
preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// accessToken 检查
|
||||
if (!accessStore.accessToken) {
|
||||
// 明确声明忽略权限访问权限,则可以访问
|
||||
if (to.meta.ignoreAccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 没有访问权限,跳转登录页面
|
||||
if (to.fullPath !== LOGIN_PATH) {
|
||||
return {
|
||||
path: LOGIN_PATH,
|
||||
// 如不需要,直接删除 query
|
||||
query:
|
||||
to.fullPath === preferences.app.defaultHomePath
|
||||
? {}
|
||||
: { redirect: encodeURIComponent(to.fullPath) },
|
||||
// 携带当前跳转的页面,登录后重新跳转该页面
|
||||
replace: true,
|
||||
};
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 生成路由表
|
||||
// 当前登录用户拥有的角色标识列表
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
const userRoles = userInfo.roles ?? [];
|
||||
|
||||
// 生成菜单和路由
|
||||
const { accessibleMenus, accessibleRoutes } = await generateAccess({
|
||||
roles: userRoles,
|
||||
router,
|
||||
// 则会在菜单中显示,但是访问会被重定向到403
|
||||
routes: accessRoutes,
|
||||
});
|
||||
|
||||
// 保存菜单信息和路由信息
|
||||
accessStore.setAccessMenus(accessibleMenus);
|
||||
accessStore.setAccessRoutes(accessibleRoutes);
|
||||
accessStore.setIsAccessChecked(true);
|
||||
const redirectPath = (from.query.redirect ??
|
||||
(to.path === preferences.app.defaultHomePath
|
||||
? userInfo.homePath || preferences.app.defaultHomePath
|
||||
: to.fullPath)) as string;
|
||||
|
||||
return {
|
||||
...router.resolve(decodeURIComponent(redirectPath)),
|
||||
replace: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function createRouterGuard(router: Router) {
|
||||
/** 通用 */
|
||||
setupCommonGuard(router);
|
||||
/** 权限访问 */
|
||||
setupAccessGuard(router);
|
||||
}
|
||||
|
||||
export { createRouterGuard };
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
createWebHistory,
|
||||
} from 'vue-router';
|
||||
|
||||
import { resetStaticRoutes } from '@vben/utils';
|
||||
|
||||
import { createRouterGuard } from './guard';
|
||||
import { routes } from './routes';
|
||||
|
||||
/**
|
||||
* @zh_CN 创建vue-router实例
|
||||
*/
|
||||
const router = createRouter({
|
||||
history:
|
||||
import.meta.env.VITE_ROUTER_HISTORY === 'hash'
|
||||
? createWebHashHistory(import.meta.env.VITE_BASE)
|
||||
: createWebHistory(import.meta.env.VITE_BASE),
|
||||
// 应该添加到路由的初始路由列表。
|
||||
routes,
|
||||
scrollBehavior: (to, _from, savedPosition) => {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
}
|
||||
return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
|
||||
},
|
||||
// 是否应该禁止尾部斜杠。
|
||||
// strict: true,
|
||||
});
|
||||
|
||||
const resetRoutes = () => resetStaticRoutes(router, routes);
|
||||
|
||||
// 创建路由守卫
|
||||
createRouterGuard(router);
|
||||
|
||||
export { resetRoutes, router };
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const BasicLayout = () => import('#/layouts/basic.vue');
|
||||
const AuthPageLayout = () => import('#/layouts/auth.vue');
|
||||
/** 全局404页面 */
|
||||
const fallbackNotFoundRoute: RouteRecordRaw = {
|
||||
component: () => import('#/views/_core/fallback/not-found.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
title: '404',
|
||||
},
|
||||
name: 'FallbackNotFound',
|
||||
path: '/:path(.*)*',
|
||||
};
|
||||
|
||||
/** 基本路由,这些路由是必须存在的 */
|
||||
const coreRoutes: RouteRecordRaw[] = [
|
||||
/**
|
||||
* 根路由
|
||||
* 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。
|
||||
* 此路由必须存在,且不应修改
|
||||
*/
|
||||
{
|
||||
component: BasicLayout,
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
title: 'Root',
|
||||
},
|
||||
name: 'Root',
|
||||
path: '/',
|
||||
redirect: preferences.app.defaultHomePath,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
component: AuthPageLayout,
|
||||
meta: {
|
||||
hideInTab: true,
|
||||
title: 'Authentication',
|
||||
},
|
||||
name: 'Authentication',
|
||||
path: '/auth',
|
||||
redirect: LOGIN_PATH,
|
||||
children: [
|
||||
// 只保留账号密码登录。短信登录、扫码登录、注册、找回密码这四个页面是模板
|
||||
// 自带的演示页,后端没有对应接口,路由留着就是一堆能点进去但走不通的死路。
|
||||
{
|
||||
name: 'Login',
|
||||
path: 'login',
|
||||
component: () => import('#/views/_core/authentication/login.vue'),
|
||||
meta: {
|
||||
title: $t('page.auth.login'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export { coreRoutes, fallbackNotFoundRoute };
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { mergeRouteModules, traverseTreeValues } from '@vben/utils';
|
||||
|
||||
import { coreRoutes, fallbackNotFoundRoute } from './core';
|
||||
|
||||
const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
// 有需要可以自行打开注释,并创建文件夹
|
||||
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
|
||||
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
|
||||
|
||||
/** 动态路由 */
|
||||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
||||
|
||||
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
|
||||
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
||||
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
||||
const staticRoutes: RouteRecordRaw[] = [];
|
||||
const externalRoutes: RouteRecordRaw[] = [];
|
||||
|
||||
/** 路由列表,由基本路由、外部路由和404兜底路由组成
|
||||
* 无需走权限验证(会一直显示在菜单中) */
|
||||
const routes: RouteRecordRaw[] = [
|
||||
...coreRoutes,
|
||||
...externalRoutes,
|
||||
fallbackNotFoundRoute,
|
||||
];
|
||||
|
||||
/** 基本路由列表,这些路由不需要进入权限拦截 */
|
||||
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
|
||||
|
||||
/** 有权限校验的路由列表,包含动态路由和静态路由 */
|
||||
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
|
||||
export { accessRoutes, coreRouteNames, routes };
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
/**
|
||||
* 业务路由。
|
||||
*
|
||||
* `meta.authority` 里写的是**权限码**,不是角色名——和后端 `require("model:write")`
|
||||
* 判的是同一个东西。加一个角色只是在后台勾几个框,前端一行代码都不用改。
|
||||
*
|
||||
* 前端只负责"看不见",后端负责"进不去"。两者缺一不可:只做前端等于没做,
|
||||
* 任何人拿 curl 都能绕过去;只做后端则是用户点进去才报错,体验很差。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
name: 'KnowledgeCenter',
|
||||
path: '/knowledge',
|
||||
meta: { icon: 'lucide:book-open-check', order: 3, title: '知识中心', authority: ['knowledge:read'] },
|
||||
children: [{
|
||||
name: 'KnowledgeWorkspace',
|
||||
path: 'center',
|
||||
component: () => import('#/views/knowledge/center.vue'),
|
||||
meta: { icon: 'lucide:library', title: '知识加工与检索', authority: ['knowledge:read'] },
|
||||
}],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:bot',
|
||||
order: 0,
|
||||
title: 'AI 模型',
|
||||
},
|
||||
name: 'ModelCenter',
|
||||
path: '/model',
|
||||
children: [
|
||||
{
|
||||
name: 'ModelCatalog',
|
||||
path: 'catalog',
|
||||
component: () => import('#/views/console/model-catalog.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:layers',
|
||||
title: '模型清单',
|
||||
authority: ['model:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ModelPlan',
|
||||
path: 'plan',
|
||||
component: () => import('#/views/console/model-plan.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:git-branch',
|
||||
title: '角色编排',
|
||||
authority: ['model:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AgentCenter',
|
||||
path: 'agents',
|
||||
component: () => import('#/views/console/agents.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:users-round',
|
||||
title: '智能体',
|
||||
authority: ['agent:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CallStats',
|
||||
path: 'stats',
|
||||
component: () => import('#/views/console/call-stats.vue'),
|
||||
meta: {
|
||||
// 首页,标签栏里固定住不让关
|
||||
affixTab: true,
|
||||
icon: 'lucide:bar-chart-3',
|
||||
title: '调用统计',
|
||||
authority: ['stats:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:monitor-smartphone',
|
||||
order: 1,
|
||||
title: '桌面端',
|
||||
},
|
||||
name: 'DesktopCenter',
|
||||
path: '/desktop',
|
||||
children: [
|
||||
{
|
||||
name: 'DesktopAccounts',
|
||||
path: 'accounts',
|
||||
component: () => import('#/views/console/desktop-accounts.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:monitor-check',
|
||||
title: '客户端账号',
|
||||
authority: ['desktop-account:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'DesktopConfig',
|
||||
path: 'config',
|
||||
component: () => import('#/views/console/desktop-config.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:sliders-horizontal',
|
||||
title: '客户端配置',
|
||||
// 只读用户也该能看这份配置——排查问题时"线上到底配的什么"是第一个
|
||||
// 要回答的问题。改动才需要 config:write。
|
||||
authority: ['config:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AppRelease',
|
||||
path: 'release',
|
||||
component: () => import('#/views/console/release.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:package-check',
|
||||
title: '版本升级',
|
||||
authority: ['config:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:messages-square',
|
||||
order: 2,
|
||||
title: '聊天归档',
|
||||
},
|
||||
name: 'ArchiveCenter',
|
||||
path: '/archive',
|
||||
children: [
|
||||
{
|
||||
name: 'ArchiveOverview',
|
||||
path: 'overview',
|
||||
component: () => import('#/views/archive/overview.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:gauge',
|
||||
title: '归档概览',
|
||||
authority: ['im:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveConversations',
|
||||
path: 'conversations',
|
||||
component: () => import('#/views/archive/conversations.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:message-circle-more',
|
||||
title: '会话与消息',
|
||||
authority: ['im:content:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveExports',
|
||||
path: 'exports',
|
||||
component: () => import('#/views/archive/exports.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:file-down',
|
||||
title: '导出中心',
|
||||
authority: ['im:export'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchivePeople',
|
||||
path: 'people',
|
||||
component: () => import('#/views/archive/people.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:contact-round',
|
||||
title: '人员唯一标识',
|
||||
authority: ['im:identity:write'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveStorage',
|
||||
path: 'storage',
|
||||
component: () => import('#/views/archive/storage.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:cloud-cog',
|
||||
title: 'COS 存储',
|
||||
authority: ['im:storage:write'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:shield',
|
||||
order: 3,
|
||||
title: '系统管理',
|
||||
},
|
||||
name: 'SystemCenter',
|
||||
path: '/system',
|
||||
children: [
|
||||
{
|
||||
name: 'RoleManage',
|
||||
path: 'roles',
|
||||
component: () => import('#/views/console/roles.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:key-round',
|
||||
title: '角色权限',
|
||||
authority: ['user:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'UserManage',
|
||||
path: 'users',
|
||||
component: () => import('#/views/console/users.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:users',
|
||||
title: '用户管理',
|
||||
authority: ['user:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AuditLog',
|
||||
path: 'audit',
|
||||
component: () => import('#/views/console/audit.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:scroll-text',
|
||||
title: '审计日志',
|
||||
authority: ['audit:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: { hideInMenu: true, title: '个人设置' },
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
children: [
|
||||
{
|
||||
name: 'ChangePassword',
|
||||
path: 'password',
|
||||
component: () => import('#/views/console/change-password.vue'),
|
||||
meta: { hideInMenu: true, title: '修改密码' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Recordable, UserInfo } from '@vben/types';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
|
||||
|
||||
import { notification } from 'ant-design-vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessStore = useAccessStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const loginLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
* @param params 登录表单数据
|
||||
*/
|
||||
async function authLogin(
|
||||
params: Recordable<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
const { accessToken } = await loginApi(params);
|
||||
|
||||
// 如果成功获取到 accessToken
|
||||
if (accessToken) {
|
||||
accessStore.setAccessToken(accessToken);
|
||||
|
||||
// 获取用户信息并存储到 accessStore 中
|
||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
getAccessCodesApi(),
|
||||
]);
|
||||
|
||||
userInfo = fetchUserInfoResult;
|
||||
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.realName) {
|
||||
notification.success({
|
||||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
|
||||
duration: 3,
|
||||
message: $t('authentication.loginSuccess'),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
async function logout(redirect: boolean = true) {
|
||||
try {
|
||||
await logoutApi();
|
||||
} catch {
|
||||
// 不做任何处理
|
||||
}
|
||||
resetAllStores();
|
||||
accessStore.setLoginExpired(false);
|
||||
|
||||
// 回登录页带上当前路由地址
|
||||
await router.replace({
|
||||
path: LOGIN_PATH,
|
||||
query: redirect
|
||||
? {
|
||||
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
|
||||
}
|
||||
: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
const userInfo = await getUserInfoApi();
|
||||
userStore.setUserInfo(userInfo);
|
||||
// 权限码要在这里一起刷新。
|
||||
//
|
||||
// `roles` 里塞的就是权限码(见 api/core/user.ts),路由守卫每次刷新页面都会
|
||||
// 重新拉一次 /me;而 `accessCodes` 只在登录那一次写过,并且被持久化在
|
||||
// localStorage 里。两份数据同源却不同步,后果是后端新增一个权限码之后,
|
||||
// 已经登录的浏览器里:菜单能出来(按 roles 过滤),但页面上所有写操作按钮
|
||||
// 全是灰的(按 accessCodes 判),而且刷新多少次都不会好——只有退出重登才
|
||||
// 会好。这种"看得见、点不动、查不出原因"的状态,比直接报错难查得多。
|
||||
accessStore.setAccessCodes(userInfo.roles ?? []);
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
function $reset() {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
$reset,
|
||||
authLogin,
|
||||
fetchUserInfo,
|
||||
loginLoading,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
@@ -0,0 +1,3 @@
|
||||
# \_core
|
||||
|
||||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { About } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'About' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<About />
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: 'example@example.com',
|
||||
},
|
||||
fieldName: 'email',
|
||||
label: $t('authentication.email'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.emailTip') })
|
||||
.email($t('authentication.emailValidErrorTip')),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationForgetPassword
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, z } from '@vben/common-ui';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 模板自带的演示账号下拉、滑块验证码、手机号/扫码/第三方登录、注册和找回密码
|
||||
// 都删掉了——后端没有对应的接口,留着只会让人点进死路。滑块验证码尤其要注意:
|
||||
// 它是纯前端的,挡不住脚本,只挡真人。真要防爆破得在服务端做失败计数。
|
||||
const formSchema = computed((): VbenFormSchema[] => [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '用户名' },
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
rules: z.string().min(1, { message: '请输入用户名' }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: { placeholder: '密码' },
|
||||
fieldName: 'password',
|
||||
label: '密码',
|
||||
rules: z.string().min(1, { message: '请输入密码' }),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="authStore.loginLoading"
|
||||
:show-code-login="false"
|
||||
:show-forget-password="false"
|
||||
:show-qrcode-login="false"
|
||||
:show-register="false"
|
||||
:show-third-party-login="false"
|
||||
sub-title="请使用管理员分配的账号登录"
|
||||
title="真羊 AI 客服 · 管理后台"
|
||||
@submit="authStore.authLogin"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
|
||||
import { AuthenticationRegister, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'Register' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('authentication.passwordStrength'),
|
||||
};
|
||||
},
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.confirmPassword'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { password } = values;
|
||||
return z
|
||||
.string({ error: $t('authentication.passwordTip') })
|
||||
.min(1, { message: $t('authentication.passwordTip') })
|
||||
.refine((value) => value === password, {
|
||||
message: $t('authentication.confirmPasswordTip'),
|
||||
});
|
||||
},
|
||||
triggerFields: ['password'],
|
||||
},
|
||||
fieldName: 'confirmPassword',
|
||||
label: $t('authentication.confirmPassword'),
|
||||
},
|
||||
{
|
||||
component: 'VbenCheckbox',
|
||||
fieldName: 'agreePolicy',
|
||||
renderComponentContent: () => ({
|
||||
default: () =>
|
||||
h('span', [
|
||||
$t('authentication.agree'),
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'vben-link ml-1 ',
|
||||
href: '',
|
||||
},
|
||||
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
|
||||
),
|
||||
]),
|
||||
}),
|
||||
rules: z.boolean().refine((value) => !!value, {
|
||||
message: $t('authentication.agreeTip'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationRegister
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback403Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="403" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback500Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="500" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback404Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="404" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'FallbackOfflineDemo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="offline" />
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: '管理员',
|
||||
value: 'super',
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'realName',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
},
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getUserInfoApi();
|
||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user