Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules/
frontend/node_modules/
# Frontend build output
frontend/dist/
# Wails / build output
build/bin/
build/tmp/
build/windows/installer/runtime/
*.syso
# Go cache and test output
.gocache/
*.test
*.out
coverage.out
# Runtime data and logs
Log/
**/Log/
*.log
operations/*_operations.json
config/client_status.json
helper/config/client_status.json
config/knowledge/embedding_index.json
# Local/editor files
.vscode/
.idea/
.DS_Store
Thumbs.db
# Keep release assets intentionally copied for handoff.
!release/
!release/**
frontend/.npm-cache/
frontend/**/.npm-cache/
tools/**/.gocache*/
helper/**/.gocache*/

122
AUTO_REPLY.md Normal file
View File

@@ -0,0 +1,122 @@
# 自动客服使用说明
## 运行方式
1. 启动 `build/bin/qiweimanager.exe`
2. 确认当前企业微信账号已经被工具接管。这里的“机器人”就是当前登录并被接管的企微账号。
3. 进入左侧菜单“自动客服”。
4. 配置 AI、知识库、人工接管信息。
5. 点击“重建知识库索引”。
6. 点击“测试 AI”和“测试人工私信”确认可用。
7. 打开总开关后helper 会开始监听入站消息并自动处理。
## 知识库目录
默认目录是:
```text
config/knowledge
```
如果从发布目录运行,则实际目录通常是:
```text
build/bin/config/knowledge
```
支持的文件格式:
```text
.md
.txt
.csv
.xlsx
.docx
.pdf
```
建议把常见问题、产品介绍、价格说明、售后规则等内容拆成短段落。每次增删改知识库文件后,都需要在“自动客服”页面点击“重建知识库索引”。
PDF 解析目前是基础文本提取,扫描件或复杂编码 PDF 可能无法提取出有效内容。遇到这种文件,建议先转成 `.md``.txt``.docx``.xlsx`
## AI 配置
第一种方式是 OpenAI-compatible 接口,例如:
```text
Provider: openai
Base URL: https://api.openai.com
Model: gpt-4o-mini
API Key: sk-...
```
程序会请求:
```text
/v1/chat/completions
```
第二种方式是本地 Ollama
```text
Provider: ollama
Base URL: http://localhost:11434
Model: qwen2.5:7b
```
程序会请求:
```text
/api/chat
```
无论使用哪种 AI提示词都会要求 AI 只能基于知识库片段回答。知识库没有答案时AI 应输出 `NO_ANSWER`,系统会自动转人工。
## 消息触发规则
自动客服只处理文本消息:
```text
event = 20002
type = 11041
```
私聊文本会自动处理。群聊文本只有在消息 @ 当前接管账号时才会处理。自己发送的消息会被忽略,重复消息会按 `robotId + conversationId + serverId/localId` 去重。
以下情况会转人工:
```text
知识库匹配分数太低
AI 超时或失败
AI 返回空内容
AI 返回 NO_ANSWER
命中敏感关键词
问题过长
自动回复发送失败
```
默认敏感关键词包括退款、投诉、合同、发票、赔偿、价格审批等,可在前端配置里调整。
## 转人工配置
优先填写 `humanConversationId`,这是最稳定的私信会话 ID。
如果没有 `humanConversationId`,可以填写 `humanUserId`。系统会尝试推导:
```text
S:<robotId>_<humanUserId>
```
推导不一定适用于所有企业微信版本,所以配置后请点击“测试人工私信”。测试成功后,再打开自动客服总开关。
## 已保留的兼容接口
以下原有接口保持不变:
```text
POST /api/send-wxwork-data
POST /api/third-party-request
```
原有 callback 推送、dashboard、requestdata 模板调用链也保持兼容。

64
BUG_FIX_REPORT.md Normal file
View File

@@ -0,0 +1,64 @@
# Bug修复报告
## 问题描述
用户在使用自动客服功能时,当询问"介绍公司产品"或"介绍公司设备"等问题时,系统错误地发送了素材库中的"猫猫视频",而不是发送相关的产品介绍素材。
## 问题原因
`helper/auto_reply_materials.go` 文件的 `matchMaterials` 函数中第48-104行素材匹配逻辑存在缺陷
**原始代码第67-70行**
```go
searchText := strings.ToLower(strings.TrimSpace(searchContext))
for _, hit := range hits {
searchText += "\n" + strings.ToLower(hit.Title+" "+hit.Content+" "+hit.Source)
}
```
问题在于:
1. 系统先使用用户的问题搜索知识库
2. 知识库搜索结果hits的内容被加入到素材匹配的 searchText 中
3. 如果知识库中包含"猫"、"视频"等关键词,就会错误地匹配到"猫猫视频"素材
4. 导致发送了不相关的素材
## 修复方案
**修改后的代码第67-70行**
```go
// 只使用用户的原始问题进行素材匹配,不包含知识库搜索结果
// 这样可以避免知识库内容中的关键词干扰素材匹配
searchText := strings.ToLower(strings.TrimSpace(userQuery))
```
**核心改进:**
- 素材匹配现在只基于用户的原始问题userQuery
- 不再将知识库搜索结果混入素材匹配逻辑
- 知识库搜索结果仅用于AI生成回答不影响素材选择
## 测试验证
修复后的行为:
- 用户问"我要猫猫照片" → 正确发送猫猫照片素材
- 用户问"我要猫猫视频" → 正确发送猫猫视频素材
- 用户问"介绍公司产品" → 只会在素材库有明确匹配"产品"关键词的素材时才发送,不会因为知识库中出现其他词而误发
## 编译信息
- 修复时间2026-06-15 15:16
- 修改文件helper/auto_reply_materials.go
- 已编译文件:
- build/bin/qiweimanager.exe (126MB)
- build/bin/helper.exe (16MB) - 包含修复
- MD5校验
- helper.exe: f92830d586af6e2646d59fd7acf3cfb0
- qiweimanager.exe: 3f853a55f0d8574f90322f36d31424ea
## 部署说明
1. 停止正在运行的 qiweimanager.exe 和 helper.exe
2. 用新编译的文件替换原文件:
- 复制 `build/bin/qiweimanager.exe` 到运行目录
- 复制 `build/bin/helper.exe` 到运行目录
3. 重新启动程序
4. 测试素材匹配功能是否正常
## 注意事项
- 此修复不影响现有配置文件和知识库
- 无需重建知识库索引
- 素材库materials/)目录保持不变
- 建议在测试环境验证后再部署到生产环境

BIN
Helper_4.1.33.6009.dll Normal file

Binary file not shown.

BIN
Loader_4.1.33.6009.dll Normal file

Binary file not shown.

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# README
## About
This is the official Wails Vanilla template.
You can configure the project by editing `wails.json`. More information about the project settings can be found
here: https://wails.io/docs/reference/project-config
## Live Development
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building
To build a redistributable, production mode package, use `wails build`.

552
USAGE.md Normal file
View File

@@ -0,0 +1,552 @@
# qiweimanager 项目解析与使用说明
更新时间2026-05-18
## 1. 当前环境与部署结果
本项目是一个 Windows 桌面端企业微信辅助管理工具,界面使用 Wails + Vue 3主程序由 Go 编写,另有一个 `helper.exe` 辅助进程通过本地 HTTP 服务、DLL 调用和回调模板与企业微信交互。
本机环境已经部署并验证:
| 项目 | 当前状态 |
| --- | --- |
| Go | `go1.26.2 windows/amd64`,项目 `go.mod` 要求 `go 1.23` |
| Node.js | `v23.8.0` |
| npm | `10.9.2` |
| Wails CLI | `v2.10.2`,已安装在 `%USERPROFILE%\go\bin\wails.exe` |
| WebView2 | Wails Doctor 检测已安装,版本 `148.0.3967.70` |
| 前端依赖 | 已执行 `npm install`,依赖在 `frontend/node_modules` |
| 前端产物 | 已生成 `frontend/dist` |
| 主程序构建 | 已通过 `go build ./...``wails build` |
| 发布目录 | 已生成并补齐 `build/bin` |
当前可直接运行的发布目录:
```text
build/bin/
qiweimanager.exe
helper.exe
Helper_4.1.33.6009.dll
Loader_4.1.33.6009.dll
config/
config.json
client_status.json
requestdata/
*.json
eventdata/
*.json
```
推荐从 `build/bin/qiweimanager.exe` 启动;当前项目根目录的 `qiweimanager.exe` 也已同步为 Wails 正式构建版本,可以直接双击运行。根目录原本自带的旧 exe 已备份为 `qiweimanager.old-invalid-build.exe`
## 2. 项目整体架构
```text
Wails 主程序 qiweimanager.exe
├─ 嵌入 frontend/dist展示 Vue 桌面界面
├─ 读取/保存 config/config.json
├─ 管理日志和操作记录
├─ 启动同级目录 helper.exe
└─ 通过 http://localhost:<httpPort> 调用 helper
helper.exe
├─ 启动本地 HTTP REST 服务,默认端口 10001
├─ 加载 Helper_4.1.33.6009.dll 和 Loader_4.1.33.6009.dll
├─ 注入/连接企业微信 WXWork.exe
├─ 将请求模板 requestdata/*.json 转换成底层消息 type
├─ 将企业微信事件 eventdata/*.json 转换成回调格式
└─ 将事件回调到 config.json 中配置的 callbackUrl
```
主要运行链路:
1. 启动 `qiweimanager.exe`
2. 主程序初始化日志、配置、WebView2并启动 `helper.exe`
3. `helper.exe` 加载 DLL启动本地 HTTP 服务。
4. 前端调用 Wails 绑定方法,例如 `SendWxWorkData``GetCallbackConfig`
5. 主程序把指令转发到 `helper.exe``/api/send-wxwork-data`
6. `helper.exe` 根据消息类型调用 DLL 或转换模板。
7. 企业微信回调数据进入 `MyRecvCallback` 后,经 `eventdata` 模板转换,再按配置推送到第三方回调地址。
## 3. 目录和文件说明
| 路径 | 作用 |
| --- | --- |
| `main.go` | Wails 主入口。初始化日志、配置、WebView2、单实例锁启动/关闭 `helper.exe`。 |
| `app.go` | 暴露给前端的 Wails 方法,包含配置保存、发送企微数据、系统内存、账号列表、操作记录等。 |
| `http_client.go` | 主程序访问 helper 本地 HTTP 服务的客户端。 |
| `operation_record.go` | 操作日志 JSON 文件读写、分页、清理。 |
| `go.mod` / `go.sum` | Go 模块依赖,核心依赖是 `github.com/wailsapp/wails/v2 v2.10.2`。 |
| `wails.json` | Wails 项目配置,定义前端安装、构建和开发命令。 |
| `build_helper.bat` | 构建 32 位 `helper.exe` 并复制 DLL 的脚本。脚本末尾有 `pause`,命令行自动化时可改用本文的 PowerShell 命令。 |
| `Helper_4.1.33.6009.dll` | 企业微信辅助 DLL运行时必须与 `helper.exe` 同级。 |
| `Loader_4.1.33.6009.dll` | 企业微信 Loader DLL运行时必须与 `helper.exe` 同级。 |
| `qiweimanager.exe` | 仓库自带的旧可执行文件。当前构建产物在 `build/bin/qiweimanager.exe`。 |
| `config/` | 主程序默认配置结构与仓库内初始 `config.json`。运行时会读取 exe 同级的 `config/config.json`。 |
| `logger/` | 日志器,按日期写入 `Log/YYYY-MM-DD/*.txt`,最大文件约 5 MB定期清理旧日志。 |
| `helper/` | 辅助进程源码,负责 DLL 加载、HTTP 服务、企业微信注入、事件转换和回调。 |
| `helper/config/client_status.json` | 示例账号状态文件。发布时已复制到 `build/bin/config/client_status.json`。 |
| `requestdata/` | 第三方请求模板。文件名对应 `/api/third-party-request``type`。 |
| `eventdata/` | 企业微信事件转换模板。文件名对应企业微信回调原始 `type`。 |
| `frontend/` | Vue 3 + Vite + Element Plus 前端工程。 |
| `frontend/src/App.vue` | 主界面:登录、系统首页、企微账号、操作记录、回调配置。 |
| `frontend/src/components/LoginModal.vue` | 登录弹窗,调用远程登录接口并保存 token 到本地配置。 |
| `frontend/src/components/WxWorkAccount.vue` | 企微账号列表,读取 `client_status.json`,支持下线/删除。 |
| `frontend/src/components/OperationLogs.vue` | 操作日志列表,读取本地操作记录。 |
| `frontend/wailsjs/` | Wails 自动生成的 JS/TS 绑定文件,不要手动改。 |
| `frontend/clear_storage.html` | 清空浏览器 localStorage 的小工具页面。 |
| `frontend/css` / `frontend/js` | 早期静态资源目录;当前入口是 `frontend/src/main.js`。 |
| `build/` | Wails 构建资源和输出目录。当前发布文件在 `build/bin`。 |
| `Log/` | 构建/运行时生成的日志目录。正式运行时会在 exe 同级目录生成日志。 |
## 4. 首次运行
1. 进入发布目录:
```powershell
cd "C:\Users\12138\Desktop\企业微信\qiweimanager-master\build\bin"
```
2. 启动主程序:
```powershell
.\qiweimanager.exe
```
3. 登录界面会要求输入账号、密码、验证码,并勾选测试学习用途声明。登录接口在前端代码中为:
```text
https://crm.yelangjiang.com/admin-api/system/auth/work-pw-login
```
4. 登录成功后进入主界面,可使用:
| 页面 | 功能 |
| --- | --- |
| 系统首页 | 查看消息累计、系统占用、活跃账号,点击“启动企微”。 |
| 企微账号 | 查看 `config/client_status.json` 中的账号状态,支持下线/删除。 |
| 操作记录 | 展示最新操作日志。 |
| 回调配置 | 配置回调地址、文件上传地址、HTTP 端口、设备编码、回调开关、云授权开关。 |
注意:企业微信相关功能依赖本机安装 `WXWork.exe`,并依赖项目自带 DLL 与目标企业微信版本兼容。
## 5. 配置文件
运行时配置位于发布目录:
```text
build/bin/config/config.json
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `callbackConfig.callbackUrl` | helper 收到事件后推送到第三方系统的 HTTP 回调地址。 |
| `callbackConfig.callbackToken` | 登录后会写入 accessToken也可作为第三方接口鉴权 token 使用。 |
| `callbackConfig.httpPort` | helper 本地 HTTP 服务端口,默认 `10001`。 |
| `callbackConfig.enableCallback` | 是否开启事件回调。 |
| `callbackConfig.enableCloudAuth` | 是否开启云管理端授权。 |
| `callbackConfig.fileUploadUrl` | 媒体/文件上传地址。 |
| `callbackConfig.deviceCode` | 设备编码。 |
| `lastUpdated` | 最后保存时间戳。 |
账号状态文件:
```text
build/bin/config/client_status.json
```
它保存企业微信账号信息和在线状态,`GetWxWorkAccountList``DeleteWxWorkAccount`、机器人列表接口都会读取该文件。
## 6. 本地 HTTP API
helper 启动后默认监听:
```text
http://localhost:10001
```
如果修改了 `config.json``httpPort`,端口会随之变化。
### 健康检查
```powershell
Invoke-RestMethod "http://localhost:10001/api/health"
```
成功时返回:
```json
{
"status": "ok",
"time": "2026-05-18 15:00:00"
}
```
### 直接发送企业微信底层指令
接口:
```text
POST /api/send-wxwork-data
```
示例:
```json
{
"clientId": "1688858204634393",
"data": {
"type": 11029,
"data": {
"conversation_id": "S:xxx",
"content": "hello"
}
}
}
```
`data` 可以是 JSON 对象,也可以是 JSON 字符串。部分类型会直接返回结果,部分类型会等待企业微信回调,超时时间约 10 秒。
特殊类型:
| type | 说明 |
| --- | --- |
| `10000` | 启动/注入企业微信。前端“启动企微”按钮使用该类型。 |
| `10002` | 获取当前活跃客户端数量。 |
| `10003` | 获取机器人列表,来自 `getVWorkAccountListRequest.json`。 |
| `11036` | 在主程序里也被用于操作日志分页读取;在模板中也表示获取内部好友列表,注意调用上下文。 |
| `99999` | 主程序调试类型,用于测试操作日志读取。 |
### 第三方请求模板接口
接口:
```text
POST /api/third-party-request
```
第三方只需要传模板名称和参数helper 会从 `requestdata/<type>.json` 找模板并替换 `{{params.xxx}}`
示例:发送文本消息
```json
{
"type": "sendVWorkTextMessage",
"params": {
"robotId": "1688858204634393",
"conversationId": "S:1688xxxx_1688xxxx",
"message": "hello"
}
}
```
模板文件一般包含两个 JSON 对象:
1. 第一个对象是第三方请求格式说明。
2. 第二个对象是转换后的企业微信底层请求。
## 7. Wails 前端绑定方法
Wails 生成的前端绑定在:
```text
frontend/wailsjs/go/main/App.js
```
当前可从前端调用的方法:
| 方法 | 作用 |
| --- | --- |
| `AddLogEntry(source, type, content, duration)` | 写入一条操作日志。 |
| `DebugLoadLogEntries()` | 加载当天最新操作日志,主要供操作记录页面使用。 |
| `DeleteWxWorkAccount(userId)` | 从 `client_status.json` 删除账号。 |
| `GetActiveClientCount()` | 查询 helper 当前活跃客户端数量。 |
| `GetCallbackConfig()` | 获取当前配置。 |
| `GetSystemMemoryUsage()` | 获取 Windows 系统内存使用率。 |
| `GetWxWorkAccountList()` | 读取企微账号列表。 |
| `Greet(name)` | Wails 模板保留的示例方法。 |
| `LogFrontend(level, message)` | 前端日志写入 Go 日志文件。 |
| `SaveCallbackConfig(jsonData)` | 保存回调配置。 |
| `SendWxWorkData(clientId, jsonData)` | 发送企业微信底层指令到 helper。 |
## 8. requestdata 模板清单
`requestdata` 目录共 44 个模板。第三方调用 `/api/third-party-request` 时,`type` 填第二列名称。
| 文件 | 第三方 type | 底层 type |
| --- | --- | --- |
| `addVWorkCardUser.json` | `addVWorkCardUser` | `11121` |
| `addVWorkDeletedUser.json` | `addVWorkDeletedUser` | `11152` |
| `addVWorkFriendRequestFromGroup.json` | `addVWorkFriendRequestFromGroup` | `11071` |
| `addVWorkSearchUser.json` | `addVWorkSearchUser` | `11053` |
| `addVWorkSearchVWorkUser.json` | `addVWorkSearchVWorkUser` | `11088` |
| `agreeVWorkUser.json` | `agreeVWorkUser` | `11064` |
| `c2cCdnDown.json` | `c2cCdnDown` | `11170` |
| `c2cCdnUpload.json` | `c2cCdnUpload` | `11115` |
| `changeVWorkGroupNameRequest.json` | `changeVWorkGroupNameRequest` | `11059` |
| `changeVWorkUserCompany.json` | `changeVWorkUserCompany` | `11057` |
| `changeVWorkUserDesc.json` | `changeVWorkUserDesc` | `11055` |
| `changeVWorkUserPhone.json` | `changeVWorkUserPhone` | `11056` |
| `changeVWorkUserRemark.json` | `changeVWorkUserRemark` | `11054` |
| `createVWorkEmptyGroupRequest.json` | `createVWorkEmptyGroupRequest` | `11125` |
| `createVWorkGroupRequest.json` | `createVWorkGroupRequest` | `11058` |
| `deleteVWorkUser.json` | `deleteVWorkUser` | `11111` |
| `delVWorkMembersRequest.json` | `delVWorkMembersRequest` | `11061` |
| `dissolveGroupVWork.json` | `dissolveGroupVWork` | `11130` |
| `getCurrentAccountInfo.json` | `getCurrentAccountInfo` | `11035` |
| `getVWorkAccountListRequest.json` | `getVWorkAccountListRequest` | `10003` |
| `getVWorkExternalFriendList.json` | `getVWorkExternalFriendList` | `11037` |
| `getVWorkFriendInfo.json` | `getVWorkFriendInfo` | `11039` |
| `getVWorkGroupList.json` | `getVWorkGroupList` | `11038` |
| `getVWorkGroupMemberList.json` | `getVWorkGroupMemberList` | `11040` |
| `getVWorkInternalFriendList.json` | `getVWorkInternalFriendList` | `11036` |
| `groupInvitationVWorkConfirmationStatus.json` | `groupInvitationVWorkConfirmationStatus` | `11089` |
| `groupNameVWorkChangeStatus.json` | `groupNameVWorkChangeStatus` | `11108` |
| `inviteVWorkMembersRequest.json` | `inviteVWorkMembersRequest` | `11060` |
| `pushVWorkGroupNotice.json` | `pushVWorkGroupNotice` | `11082` |
| `quitGroupVWork.json` | `quitGroupVWork` | `11105` |
| `searchVWorkUserInfo.json` | `searchVWorkUserInfo` | `11052` |
| `sendVWorkAppletMessage.json` | `sendVWorkAppletMessage` | `11162` |
| `sendVWorkCardMessage.json` | `sendVWorkCardMessage` | `11161` |
| `sendVWorkFileMessage.json` | `sendVWorkFileMessage` | `11031` |
| `sendVWorkGifMessage.json` | `sendVWorkGifMessage` | `11070` |
| `sendVWorkGroupAtMessage.json` | `sendVWorkGroupAtMessage` | `11069` |
| `sendVWorkImageMessage.json` | `sendVWorkImageMessage` | `11030` |
| `sendVWorkTextMessage.json` | `sendVWorkTextMessage` | `11029` |
| `sendVWorkUrlMessage.json` | `sendVWorkUrlMessage` | `11159` |
| `sendVWorkVideoMessage.json` | `sendVWorkVideoMessage` | `11067` |
| `sendVWorkVideoNumberLiveMessage.json` | `sendVWorkVideoNumberLiveMessage` | `11196` |
| `sendVWorkVideoNumberMessage.json` | `sendVWorkVideoNumberMessage` | `11172` |
| `transferVWorkGroupOwner.json` | `transferVWorkGroupOwner` | `11090` |
| `wxCdnDown.json` | `wxCdnDown` | `11171` |
## 9. eventdata 事件模板清单
`eventdata` 目录共 28 个模板。helper 接收企业微信原始事件后,会按原始 `type` 找同名文件进行转换。
| 文件 | 原始 type | 回调 event | 描述 |
| --- | --- | --- | --- |
| `11026.json` | `11026` | `20001` | 用户登录成功通知事件 |
| `11027.json` | `11027` | `20009` | 用户退出通知事件 |
| `11028.json` | `11028` | `20025` | 登录二维码通知事件 |
| `11041.json` | `11041` | `20002` | 文本消息事件 |
| `11042.json` | `11042` | `20003` | 图片消息事件 |
| `11043.json` | `11043` | `20004` | 视频消息事件 |
| `11044.json` | `11044` | `20012` | 语音消息事件 |
| `11045.json` | `11045` | `20005` | 文件消息事件 |
| `11046.json` | `11046` | `20013` | 位置消息事件 |
| `11047.json` | `11047` | `20007` | 链接消息事件 |
| `11048.json` | `11048` | `20006` | 表情消息事件 |
| `11049.json` | `11049` | `20011` | 红包消息事件 |
| `11050.json` | `11050` | `20008` | 名片消息事件 |
| `11063.json` | `11063` | `20017` | 好友申请通知事件 |
| `11066.json` | `11066` | `20059` | 小程序消息事件 |
| `11068.json` | `11068` | `20014` | 图文消息事件 |
| `11072.json` | `11072` | `20030` | 新增群成员通知事件 |
| `11073.json` | `11073` | `20022` | 群成员减少通知事件 |
| `11074.json` | `11074` | `20020` | 新增群通知事件 |
| `11075.json` | `11075` | `20023` | 主动退群通知事件 |
| `11076.json` | `11076` | `20027` | 好友新增通知事件 |
| `11077.json` | `11077` | `20018` | 好友删除通知事件 |
| `11078.json` | `11078` | `20024` | 群名称变化通知事件 |
| `11123.json` | `11123` | `20015` | 撤回消息事件 |
| `11124.json` | `11124` | `20010` | 视频号消息事件 |
| `11173.json` | `11173` | `20019` | 被删除通知事件 |
| `11174.json` | `11174` | `20026` | 登录二维码状态通知事件 |
| `11195.json` | `11195` | `20016` | 视频号直播消息事件 |
## 10. 开发环境启动
PowerShell 当前执行策略会拦截 `npm.ps1`,所以建议用 `cmd /c npm ...``npm.cmd ...`
前端单独开发:
```powershell
cd "C:\Users\12138\Desktop\企业微信\qiweimanager-master\frontend"
cmd /c npm install --cache .npm-cache
cmd /c npm run dev
```
Wails 开发模式:
```powershell
cd "C:\Users\12138\Desktop\企业微信\qiweimanager-master"
& "$env:USERPROFILE\go\bin\wails.exe" dev
```
如果希望直接使用 `wails` 命令,把下面路径加入用户 PATH
```text
%USERPROFILE%\go\bin
```
## 11. 重新构建发布包
完整发布建议按下面顺序执行。
### 1. 构建前端
```powershell
cd "C:\Users\12138\Desktop\企业微信\qiweimanager-master\frontend"
cmd /c npm install --cache .npm-cache
cmd /c npm run build
```
### 2. 构建 Wails 主程序
```powershell
cd "C:\Users\12138\Desktop\企业微信\qiweimanager-master"
& "$env:USERPROFILE\go\bin\wails.exe" build
```
成功后主程序位于:
```text
build/bin/qiweimanager.exe
```
### 3. 构建 helper.exe
```powershell
cd "C:\Users\12138\Desktop\企业微信\qiweimanager-master\helper"
$env:GOARCH='386'
go build -ldflags='-H windowsgui -s -w' -o ..\build\bin\helper.exe .
Remove-Item Env:GOARCH
```
### 4. 复制运行时资源
```powershell
cd "C:\Users\12138\Desktop\企业微信\qiweimanager-master"
New-Item -ItemType Directory -Force -Path build\bin\config, build\bin\requestdata, build\bin\eventdata
Copy-Item -Force Helper_4.1.33.6009.dll build\bin\Helper_4.1.33.6009.dll
Copy-Item -Force Loader_4.1.33.6009.dll build\bin\Loader_4.1.33.6009.dll
Copy-Item -Force config\config.json build\bin\config\config.json
Copy-Item -Force helper\config\client_status.json build\bin\config\client_status.json
Copy-Item -Recurse -Force requestdata\* build\bin\requestdata\
Copy-Item -Recurse -Force eventdata\* build\bin\eventdata\
```
最终把整个 `build/bin` 目录作为发布包使用。
## 12. 已做的修正
本次环境部署中修正了一个前端构建问题:
| 文件 | 修正 |
| --- | --- |
| `frontend/vite.config.js` | 将 `@` 别名从 `'/src'` 改为基于 `import.meta.url``frontend/src` 绝对路径,避免 Windows/esbuild 在沙箱或受限目录下错误访问磁盘根目录。 |
如果不做该修正,`npm run build` 会报:
```text
Cannot read directory "../../../..": Access is denied.
```
## 13. 验证记录
已经通过的验证命令:
```powershell
go list ./...
go build ./...
cmd /c npm run build
& "$env:USERPROFILE\go\bin\wails.exe" doctor
& "$env:USERPROFILE\go\bin\wails.exe" build
```
验证结果:
| 项目 | 结果 |
| --- | --- |
| Go 包解析 | 通过,识别 `qiweimanager``qiweimanager/config``qiweimanager/helper``qiweimanager/logger`。 |
| Go 编译 | 通过。 |
| Vite 构建 | 通过,生成 `frontend/dist`。有 chunk 超过 500 KiB 的提醒,不影响运行。 |
| Wails Doctor | 通过,系统可用于 Wails 开发。 |
| Wails Build | 通过,生成 `build/bin/qiweimanager.exe`。 |
| helper 构建 | 通过,生成 `build/bin/helper.exe`。 |
| 发布资源 | 已复制 DLL、配置、请求模板、事件模板到 `build/bin`。 |
## 14. 常见问题
### PowerShell 执行 `npm` 被拦截
现象:
```text
npm.ps1因为在此系统上禁止运行脚本
```
处理方式:
```powershell
cmd /c npm -v
cmd /c npm install
cmd /c npm run build
```
### `wails` 命令找不到
Wails 已安装,但可能不在 PATH。使用完整路径
```powershell
& "$env:USERPROFILE\go\bin\wails.exe" version
```
或者把 `%USERPROFILE%\go\bin` 加入 PATH。
### 启动后 helper 无法工作
检查 `qiweimanager.exe` 同级是否有:
```text
helper.exe
Helper_4.1.33.6009.dll
Loader_4.1.33.6009.dll
config/config.json
config/client_status.json
requestdata/
eventdata/
```
还要确认企业微信 `WXWork.exe` 已安装DLL 与企业微信版本兼容。
### 本地 HTTP 接口不通
检查 `config/config.json` 中的端口:
```json
{
"callbackConfig": {
"httpPort": "10001"
}
}
```
然后测试:
```powershell
Invoke-RestMethod "http://localhost:10001/api/health"
```
如果端口被占用,改 `httpPort` 后重启应用。
### 语音消息转换失败
`helper/client_id_handler.go` 中语音转换会查找 `ffmpeg`。处理方式:
1.`ffmpeg.exe` 放到 `build/bin`
2. 或者把 ffmpeg 加入系统 PATH。
### 日志在哪里
正式运行后通常在 exe 同级目录生成:
```text
Log/YYYY-MM-DD/*.txt
operations/YYYY-MM-DD_operations.json
```
主程序日志、前端日志、helper 日志、操作记录都会写入本地文件,排查问题优先看这些目录。

47
WINDOWS_RELEASE.md Normal file
View File

@@ -0,0 +1,47 @@
# Windows 发布打包说明
本项目是 Wails 桌面应用,发布包基于现有 Go + Vue + Wails 架构生成,不需要 Electron。
## 开发机依赖
最终用户不需要安装这些工具;只有打包机器需要:
- Go
- Node.js / npm
- Wails CLI
- NSIS `makensis.exe`
如果 NSIS 没有加入 PATH可以在执行脚本时传入 `-MakensisPath`
## 一键生成安装包
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\package-windows.ps1
```
如果 NSIS 不在 PATH
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\package-windows.ps1 -MakensisPath "C:\Program Files (x86)\NSIS\makensis.exe"
```
输出文件:
```text
build/bin/qiweimanager-amd64-installer.exe
```
## 安装包行为
- 当前用户安装,不需要管理员权限。
- 默认目录:`%LOCALAPPDATA%\Programs\QiweiManager`
- 自动安装/检测 WebView2 Runtime。
- 安装包包含 `qiweimanager.exe``helper.exe`、企业微信 Helper/Loader DLL、`requestdata/``eventdata/`
- 升级安装会覆盖程序和模板,但不会覆盖已有 `config/config.json``config/client_status.json``config/knowledge/`
- 安装时会删除旧版残留 `helper_auto_reply.exe`,避免主程序优先启动旧 helper。
## 注意事项
- 打包脚本会在构建前关闭正在运行的 `helper.exe` / `helper_auto_reply.exe`,避免 Windows 文件锁导致 helper 覆盖失败。
- 代码签名入口保留在 NSIS 脚本中,默认不启用。
- `ffmpeg.exe` 暂未作为必需资源打包;需要完整语音转换能力时,可后续加入安装资源。

858
after_sales.go Normal file
View File

@@ -0,0 +1,858 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/xuri/excelize/v2"
)
type AfterSalesIssue struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ConversationID string `json:"conversationId"`
RoomName string `json:"roomName"`
SourceClientID int32 `json:"sourceClientId"`
SourceAccountUserID string `json:"sourceAccountUserId"`
SourceAccountName string `json:"sourceAccountName"`
CustomerUserID string `json:"customerUserId"`
CustomerName string `json:"customerName"`
IssueContent string `json:"issueContent"`
ImagePaths []string `json:"imagePaths"`
ImageRefs []string `json:"imageRefs"`
FileAttachments []AfterSalesFileAttachment `json:"fileAttachments"`
AISuggestion string `json:"aiSuggestion"`
Status string `json:"status"`
SourceMessageIDs []string `json:"sourceMessageIds"`
Fingerprint string `json:"fingerprint"`
CollectBatchID string `json:"collectBatchId"`
AIConfidence float64 `json:"aiConfidence"`
AISuggestionEdited bool `json:"aiSuggestionEdited"`
AssignedEngineerID string `json:"assignedEngineerId"`
AssignedEngineerName string `json:"assignedEngineerName"`
DispatchStatus string `json:"dispatchStatus"`
DispatchReason string `json:"dispatchReason"`
DispatchRuleID string `json:"dispatchRuleId"`
DispatchConfidence float64 `json:"dispatchConfidence"`
DispatchSource string `json:"dispatchSource"`
NotifyStatus string `json:"notifyStatus"`
LastNotifiedAt int64 `json:"lastNotifiedAt"`
NotifyError string `json:"notifyError"`
NotifyCount int `json:"notifyCount"`
ResolutionContent string `json:"resolutionContent"`
ResolvedAt string `json:"resolvedAt"`
KnowledgeArchivedAt string `json:"knowledgeArchivedAt"`
KnowledgeSourcePath string `json:"knowledgeSourcePath"`
}
type AfterSalesFileAttachment struct {
Name string `json:"name"`
Path string `json:"path"`
Ref string `json:"ref"`
Content string `json:"content"`
ExtractStatus string `json:"extractStatus"`
SourceMessageID string `json:"sourceMessageId"`
}
type AfterSalesHistoryImportRequest struct {
ConversationID string `json:"conversationId"`
RoomName string `json:"roomName"`
RawText string `json:"rawText"`
}
type AfterSalesKnowledgeArchive struct {
ID string `json:"id"`
FileName string `json:"fileName"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
IssueCount int `json:"issueCount"`
IssueIDs []string `json:"issueIds,omitempty"`
MissingFile bool `json:"missingFile,omitempty"`
DisplayTime string `json:"displayTime,omitempty"`
}
type AfterSalesKnowledgeCase struct {
IssueID string `json:"issueId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ResolvedAt string `json:"resolvedAt"`
KnowledgeArchivedAt string `json:"knowledgeArchivedAt"`
ConversationID string `json:"conversationId"`
RoomName string `json:"roomName"`
CustomerUserID string `json:"customerUserId"`
CustomerName string `json:"customerName"`
IssueContent string `json:"issueContent"`
AISuggestion string `json:"aiSuggestion"`
ResolutionContent string `json:"resolutionContent"`
AssignedEngineerID string `json:"assignedEngineerId"`
AssignedEngineerName string `json:"assignedEngineerName"`
ImageCount int `json:"imageCount"`
MarkdownPath string `json:"markdownPath"`
MissingMarkdown bool `json:"missingMarkdown,omitempty"`
}
type AfterSalesKnowledgeSummary struct {
PendingCount int `json:"pendingCount"`
TotalCount int `json:"totalCount"`
ArchiveCount int `json:"archiveCount"`
}
type afterSalesKnowledgeManifest struct {
Archives []AfterSalesKnowledgeArchive `json:"archives"`
}
// GetIssues returns all locally collected after-sales issues.
func (a *App) GetIssues() []AfterSalesIssue {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
globalLogger.Warn("闂備礁鍚嬮崕鎶藉床閼艰翰浜归柛銉墮閼歌銇勯弬鍨倯闁稿﹦鏁诲濠氬磼閵堝懏鐝濆銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err)
return []AfterSalesIssue{}
}
return issues
}
// SaveIssue creates or updates one after-sales issue.
func (a *App) SaveIssue(issue AfterSalesIssue) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/issues/save", issue)
if err != nil {
return false, err.Error()
}
return helperResultOK(result)
}
// DeleteIssue removes one after-sales issue.
func (a *App) DeleteIssue(id string) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/issues/delete", map[string]interface{}{"id": id})
if err != nil {
return false, err.Error()
}
return helperResultOK(result)
}
// ResolveAfterSalesIssue marks one issue resolved and saves it as a knowledge case.
func (a *App) ResolveAfterSalesIssue(issueId string, resolutionContent string) interface{} {
result, err := a.postHelperJSON("/api/after-sales/issues/resolve", map[string]interface{}{
"issueId": issueId,
"resolutionContent": resolutionContent,
})
if err != nil {
if result != nil {
return result
}
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// ListAfterSalesKnowledgeCases returns resolved after-sales knowledge cases.
func (a *App) ListAfterSalesKnowledgeCases() interface{} {
result, err := a.getHelperJSON("/api/after-sales/knowledge/cases")
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeCase{}}
}
return result
}
// UpdateAfterSalesKnowledgeCase updates the final resolution of a knowledge case.
func (a *App) UpdateAfterSalesKnowledgeCase(issueId string, resolutionContent string) interface{} {
result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/update", map[string]interface{}{
"issueId": issueId,
"resolutionContent": resolutionContent,
})
if err != nil {
if result != nil {
return result
}
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// RevealAfterSalesKnowledgeCase opens the generated Markdown file for one case.
func (a *App) RevealAfterSalesKnowledgeCase(issueId string) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/reveal", map[string]interface{}{"issueId": issueId})
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// ExportIssuesToExcel asks the user for an xlsx path and writes the current issues.
func (a *App) ExportIssuesToExcel() (bool, string) {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
return false, err.Error()
}
defaultName := fmt.Sprintf("after_sales_issues_%s.xlsx", time.Now().Format("2006-01-02_1504"))
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export after-sales issues",
DefaultFilename: defaultName,
Filters: []runtime.FileFilter{{
DisplayName: "Excel workbook (*.xlsx)",
Pattern: "*.xlsx",
}},
CanCreateDirectories: true,
})
if err != nil {
return false, err.Error()
}
if strings.TrimSpace(path) == "" {
return false, "export canceled"
}
if strings.ToLower(filepath.Ext(path)) != ".xlsx" {
path += ".xlsx"
}
if err := writeAfterSalesIssuesExcel(path, issues); err != nil {
return false, err.Error()
}
return true, path
}
// ListAfterSalesKnowledgeArchives returns all saved after-sales Excel archives.
func (a *App) ListAfterSalesKnowledgeArchives() interface{} {
manifest, err := readAfterSalesKnowledgeManifest()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeArchive{}}
}
archives := append([]AfterSalesKnowledgeArchive(nil), manifest.Archives...)
for i := range archives {
archives[i].MissingFile = !fileExists(archives[i].Path)
archives[i].DisplayTime = formatAfterSalesExcelTime(archives[i].CreatedAt)
}
sortAfterSalesKnowledgeArchives(archives)
return map[string]interface{}{"success": true, "message": "ok", "data": archives}
}
// GetPendingAfterSalesArchiveSummary reports how many issues have not been saved
// into the local Excel knowledge archive yet.
func (a *App) GetPendingAfterSalesArchiveSummary() interface{} {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}}
}
manifest, err := readAfterSalesKnowledgeManifest()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}}
}
summary := AfterSalesKnowledgeSummary{
PendingCount: len(filterPendingAfterSalesArchiveIssues(issues, manifest)),
TotalCount: len(issues),
ArchiveCount: len(manifest.Archives),
}
return map[string]interface{}{"success": true, "message": "ok", "data": summary}
}
// ArchivePendingAfterSalesIssues writes one new timestamped Excel archive for
// issues that have not been saved to the knowledge archive yet.
func (a *App) ArchivePendingAfterSalesIssues() interface{} {
archive, archived, err := a.archivePendingAfterSalesIssues()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": archive}
}
if !archived {
return map[string]interface{}{"success": true, "message": "no pending issues", "data": archive}
}
return map[string]interface{}{"success": true, "message": "saved to knowledge archive", "data": archive}
}
func (a *App) confirmArchivePendingAfterSalesBeforeClose(ctx context.Context) bool {
summaryResult := a.GetPendingAfterSalesArchiveSummary()
summaryMap, _ := summaryResult.(map[string]interface{})
if ok, _ := summaryMap["success"].(bool); !ok {
return false
}
summary, ok := summaryMap["data"].(AfterSalesKnowledgeSummary)
if !ok || summary.PendingCount <= 0 {
return false
}
message := fmt.Sprintf("There are %d after-sales issues not saved to the knowledge archive. Save them before exit?", summary.PendingCount)
choice, err := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.QuestionDialog,
Title: "Save after-sales issues",
Message: message,
DefaultButton: "Yes",
})
if err != nil || choice != "Yes" {
confirmExit, confirmErr := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.QuestionDialog,
Title: "Confirm exit",
Message: "Exit without saving after-sales issues to the knowledge archive? Choose No to return to the app.",
DefaultButton: "No",
})
return confirmErr != nil || confirmExit != "Yes"
}
archive, archived, err := a.archivePendingAfterSalesIssues()
if err != nil {
_, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.ErrorDialog,
Title: "Save failed",
Message: "Saving to the knowledge archive failed; exit canceled: " + err.Error(),
})
return true
}
if archived {
globalLogger.Info("Saved after-sales knowledge archive before exit: %s (%d issues)", archive.Path, archive.IssueCount)
}
return false
}
// RevealAfterSalesKnowledgeArchive opens an archive file or the archive folder.
func (a *App) RevealAfterSalesKnowledgeArchive(path string) (bool, string) {
path = strings.Trim(strings.TrimSpace(path), "\"'")
if path == "" {
path = afterSalesKnowledgeDir()
}
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
return false, err.Error()
}
if !filepath.IsAbs(path) {
path = filepath.Join(afterSalesKnowledgeDir(), filepath.Clean(path))
}
if !isPathInside(path, afterSalesKnowledgeDir()) {
return false, "can only open files inside the after-sales knowledge directory"
}
target := path
if !fileExists(target) {
target = afterSalesKnowledgeDir()
}
cmd := exec.Command("explorer.exe", target)
if err := cmd.Start(); err != nil {
return false, err.Error()
}
return true, "opened"
}
// RevealAfterSalesAttachment opens a locally saved after-sales attachment.
func (a *App) RevealAfterSalesAttachment(path string) (bool, string) {
path = strings.Trim(strings.TrimSpace(path), "\"'")
if path == "" {
return false, "empty attachment path"
}
if !filepath.IsAbs(path) {
return false, "attachment path must be absolute"
}
if !fileExists(path) {
return false, "attachment file is missing"
}
if !isAllowedAfterSalesAttachmentPath(path) {
return false, "can only open files saved by this app"
}
cmd := exec.Command("explorer.exe", path)
if err := cmd.Start(); err != nil {
return false, err.Error()
}
return true, "opened"
}
// TriggerManualCollect starts one asynchronous after-sales issue collection.
func (a *App) TriggerManualCollect(conversationID string) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/collect", map[string]interface{}{"conversationId": conversationID})
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// ImportAfterSalesHistory imports copied WeCom chat history and runs collection.
func (a *App) ImportAfterSalesHistory(req AfterSalesHistoryImportRequest) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/import-history", req)
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// PrepareWeComHistoryCopy activates WeCom and sends the copy shortcut.
// The frontend owns clipboard reads so the UI can show progress and timeouts.
func (a *App) PrepareWeComHistoryCopy() (bool, string) {
return a.prepareWeComHistoryCopy()
}
// SyncCurrentWeComChatHistory is kept for older frontends. New UI should call
// PrepareWeComHistoryCopy, read clipboard text, then ImportAfterSalesHistory.
func (a *App) SyncCurrentWeComChatHistory(req AfterSalesHistoryImportRequest) (bool, string) {
req.ConversationID = strings.TrimSpace(req.ConversationID)
req.RoomName = strings.TrimSpace(req.RoomName)
if req.ConversationID == "" || strings.EqualFold(req.ConversationID, "all") {
return false, "please select a specific group before syncing history"
}
if req.RawText == "" {
return false, "raw history text is empty"
}
result, err := a.postHelperJSON("/api/after-sales/import-history", req)
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// SetAutoCollectTask persists the hourly collection switch.
func (a *App) SetAutoCollectTask(enabled bool) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/auto-collect", map[string]interface{}{"enabled": enabled})
if err != nil {
return false, err.Error()
}
return helperResultOK(result)
}
// GetAfterSalesIssueStatus returns collection status.
func (a *App) GetAfterSalesIssueStatus() interface{} {
result, err := a.getHelperJSON("/api/after-sales/status")
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// GetAfterSalesImageData returns a local after-sales image as a browser-safe data URL.
func (a *App) GetAfterSalesImageData(path string) (string, error) {
path = strings.Trim(strings.TrimSpace(path), "\"'")
if path == "" {
return "", fmt.Errorf("image path is empty")
}
if !filepath.IsAbs(path) {
path = filepath.Clean(path)
}
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
default:
return "", fmt.Errorf("unsupported image type: %s", ext)
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
if len(data) == 0 {
return "", fmt.Errorf("image file is empty")
}
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
if !strings.HasPrefix(mimeType, "image/") {
return "", fmt.Errorf("not an image file")
}
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
func (a *App) fetchAfterSalesIssues() ([]AfterSalesIssue, error) {
result, err := a.getHelperJSON("/api/after-sales/issues")
if err != nil {
return nil, err
}
if ok, _ := result["success"].(bool); !ok {
return nil, fmt.Errorf("%v", result["message"])
}
data, err := json.Marshal(result["data"])
if err != nil {
return nil, err
}
var issues []AfterSalesIssue
if err := json.Unmarshal(data, &issues); err != nil {
return nil, err
}
if issues == nil {
issues = []AfterSalesIssue{}
}
return issues, nil
}
func (a *App) archivePendingAfterSalesIssues() (AfterSalesKnowledgeArchive, bool, error) {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
manifest, err := readAfterSalesKnowledgeManifest()
if err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
pending := filterPendingAfterSalesArchiveIssues(issues, manifest)
if len(pending) == 0 {
return AfterSalesKnowledgeArchive{}, false, nil
}
sort.Slice(pending, func(i, j int) bool {
return pending[i].CreatedAt > pending[j].CreatedAt
})
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
now := time.Now().Local()
path := nextAfterSalesKnowledgeExcelPath(now)
if err := writeAfterSalesIssuesExcel(path, pending); err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
archive := AfterSalesKnowledgeArchive{
ID: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
FileName: filepath.Base(path),
Path: path,
CreatedAt: now.Format(time.RFC3339),
IssueCount: len(pending),
IssueIDs: afterSalesIssueIDs(pending),
DisplayTime: now.Format("2006-01-02 15:04"),
}
manifest.Archives = append(manifest.Archives, archive)
sortAfterSalesKnowledgeArchives(manifest.Archives)
if err := writeAfterSalesKnowledgeManifest(manifest); err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
return archive, true, nil
}
func filterPendingAfterSalesArchiveIssues(issues []AfterSalesIssue, manifest afterSalesKnowledgeManifest) []AfterSalesIssue {
archived := make(map[string]bool)
for _, archive := range manifest.Archives {
for _, id := range archive.IssueIDs {
id = strings.TrimSpace(id)
if id != "" {
archived[id] = true
}
}
}
pending := make([]AfterSalesIssue, 0)
for _, issue := range issues {
if strings.TrimSpace(issue.ID) == "" || archived[issue.ID] {
continue
}
pending = append(pending, issue)
}
return pending
}
func readAfterSalesKnowledgeManifest() (afterSalesKnowledgeManifest, error) {
var manifest afterSalesKnowledgeManifest
data, err := os.ReadFile(afterSalesKnowledgeManifestPath())
if err != nil {
if os.IsNotExist(err) {
return manifest, nil
}
return manifest, err
}
if len(strings.TrimSpace(string(data))) == 0 {
return manifest, nil
}
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, err
}
for i := range manifest.Archives {
if manifest.Archives[i].ID == "" {
manifest.Archives[i].ID = strings.TrimSuffix(manifest.Archives[i].FileName, filepath.Ext(manifest.Archives[i].FileName))
}
if manifest.Archives[i].Path == "" && manifest.Archives[i].FileName != "" {
manifest.Archives[i].Path = filepath.Join(afterSalesKnowledgeDir(), manifest.Archives[i].FileName)
}
}
return manifest, nil
}
func writeAfterSalesKnowledgeManifest(manifest afterSalesKnowledgeManifest) error {
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
tmp := afterSalesKnowledgeManifestPath() + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, afterSalesKnowledgeManifestPath())
}
func afterSalesKnowledgeDir() string {
base, err := os.Executable()
if err != nil {
if wd, wdErr := os.Getwd(); wdErr == nil {
base = wd
}
}
if base != "" && filepath.Ext(base) != "" {
base = filepath.Dir(base)
}
return filepath.Join(base, "config", "after_sales_knowledge")
}
func afterSalesKnowledgeManifestPath() string {
return filepath.Join(afterSalesKnowledgeDir(), "manifest.json")
}
func nextAfterSalesKnowledgeExcelPath(now time.Time) string {
baseName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_1504"))
path := filepath.Join(afterSalesKnowledgeDir(), baseName+".xlsx")
if !fileExists(path) {
return path
}
secondName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_150405"))
path = filepath.Join(afterSalesKnowledgeDir(), secondName+".xlsx")
if !fileExists(path) {
return path
}
for i := 2; ; i++ {
path = filepath.Join(afterSalesKnowledgeDir(), fmt.Sprintf("%s_%02d.xlsx", secondName, i))
if !fileExists(path) {
return path
}
}
}
func afterSalesIssueIDs(issues []AfterSalesIssue) []string {
ids := make([]string, 0, len(issues))
for _, issue := range issues {
if id := strings.TrimSpace(issue.ID); id != "" {
ids = append(ids, id)
}
}
return ids
}
func sortAfterSalesKnowledgeArchives(archives []AfterSalesKnowledgeArchive) {
sort.Slice(archives, func(i, j int) bool {
if archives[i].CreatedAt != archives[j].CreatedAt {
return archives[i].CreatedAt > archives[j].CreatedAt
}
return archives[i].FileName > archives[j].FileName
})
}
func fileExists(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
func isPathInside(path string, root string) bool {
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
absRoot, err := filepath.Abs(root)
if err != nil {
return false
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
}
func isAllowedAfterSalesAttachmentPath(path string) bool {
roots := make([]string, 0, 6)
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
roots = append(roots, wd, filepath.Join(wd, "temp"))
}
if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" {
exeDir := filepath.Dir(exe)
roots = append(roots, exeDir, filepath.Join(exeDir, "temp"), filepath.Join(filepath.Dir(exeDir), "temp"))
}
if tmp := os.TempDir(); strings.TrimSpace(tmp) != "" {
roots = append(roots, tmp)
}
cleaned := filepath.Clean(path)
for _, root := range roots {
if strings.TrimSpace(root) != "" && isPathInside(cleaned, root) {
return true
}
}
return false
}
func helperResultOK(result map[string]interface{}) (bool, string) {
if ok, _ := result["success"].(bool); ok {
if msg := strings.TrimSpace(fmt.Sprint(result["message"])); msg != "" && msg != "<nil>" {
return true, msg
}
return true, "success"
}
msg := strings.TrimSpace(fmt.Sprint(result["message"]))
if msg == "" || msg == "<nil>" {
msg = "operation failed"
}
return false, msg
}
func writeAfterSalesIssuesExcel(path string, issues []AfterSalesIssue) error {
file := excelize.NewFile()
sheet := "AfterSalesIssues"
defaultSheet := file.GetSheetName(0)
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return err
}
}
headers := []string{"Created At", "问题来源账号", "Group", "Customer", "Issue Content", "Images", "Files", "File Content", "AI Suggestion", "Status", "Engineer", "Dispatch Status", "Notify Status"}
for i, header := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
_ = file.SetCellValue(sheet, cell, header)
}
for row, issue := range issues {
values := []interface{}{
formatAfterSalesExcelTime(issue.CreatedAt),
displayAfterSalesSourceAccount(issue),
issue.RoomName,
displayAfterSalesCustomerName(issue.CustomerName),
issue.IssueContent,
strings.Join(append(append([]string{}, issue.ImagePaths...), issue.ImageRefs...), "\n"),
formatAfterSalesIssueFiles(issue.FileAttachments, false),
formatAfterSalesIssueFiles(issue.FileAttachments, true),
issue.AISuggestion,
afterSalesStatusLabel(issue.Status),
displayAfterSalesEngineerName(issue),
afterSalesDispatchStatusLabel(issue.DispatchStatus),
afterSalesNotifyStatusLabel(issue.NotifyStatus),
}
for col, value := range values {
cell, _ := excelize.CoordinatesToCellName(col+1, row+2)
_ = file.SetCellValue(sheet, cell, value)
}
}
_ = file.SetColWidth(sheet, "A", "A", 18)
_ = file.SetColWidth(sheet, "B", "D", 24)
_ = file.SetColWidth(sheet, "E", "I", 42)
_ = file.SetColWidth(sheet, "J", "M", 14)
return file.SaveAs(path)
}
func formatAfterSalesIssueFiles(files []AfterSalesFileAttachment, includeContent bool) string {
if len(files) == 0 {
return ""
}
lines := make([]string, 0, len(files))
for _, file := range files {
name := strings.TrimSpace(file.Name)
if name == "" {
name = strings.TrimSpace(filepath.Base(file.Path))
}
if name == "" {
name = strings.TrimSpace(file.Ref)
}
if name == "" {
name = "attachment"
}
line := name
if strings.TrimSpace(file.Path) != "" {
line += " | " + strings.TrimSpace(file.Path)
} else if strings.TrimSpace(file.Ref) != "" {
line += " | " + strings.TrimSpace(file.Ref)
}
if strings.TrimSpace(file.ExtractStatus) != "" {
line += " | " + strings.TrimSpace(file.ExtractStatus)
}
if includeContent && strings.TrimSpace(file.Content) != "" {
line += "\n" + truncateAfterSalesExcelText(file.Content, 1200)
}
lines = append(lines, line)
}
return strings.Join(lines, "\n\n")
}
func truncateAfterSalesExcelText(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 || len([]rune(text)) <= limit {
return text
}
runes := []rune(text)
return string(runes[:limit]) + "..."
}
func displayAfterSalesCustomerName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "unknown customer"
}
return name
}
func displayAfterSalesSourceAccount(issue AfterSalesIssue) string {
parts := make([]string, 0, 3)
if name := strings.TrimSpace(issue.SourceAccountName); name != "" {
parts = append(parts, name)
}
if userID := strings.TrimSpace(issue.SourceAccountUserID); userID != "" && userID != strings.TrimSpace(issue.SourceAccountName) {
parts = append(parts, userID)
}
if issue.SourceClientID != 0 {
parts = append(parts, fmt.Sprintf("client %d", issue.SourceClientID))
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, " / ")
}
func formatAfterSalesExcelTime(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t.Local().Format("2006-01-02 15:04")
}
return value
}
func afterSalesStatusLabel(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "resolved":
return "resolved"
case "ignored":
return "ignored"
default:
return "pending"
}
}
func displayAfterSalesEngineerName(issue AfterSalesIssue) string {
if strings.TrimSpace(issue.AssignedEngineerName) != "" {
return strings.TrimSpace(issue.AssignedEngineerName)
}
return strings.TrimSpace(issue.AssignedEngineerID)
}
func afterSalesDispatchStatusLabel(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "assigned":
return "assigned"
case "suggested":
return "suggested"
default:
return "unassigned"
}
}
func afterSalesNotifyStatusLabel(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "sent":
return "sent"
case "failed":
return "failed"
default:
return "not_sent"
}
}

76
after_sales_dispatch.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"encoding/json"
"fmt"
)
// GetAfterSalesDispatchConfig returns the local engineer dispatch settings.
func (a *App) GetAfterSalesDispatchConfig() interface{} {
result, err := a.getHelperJSON("/api/after-sales/dispatch/config")
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// SaveAfterSalesDispatchConfig persists engineer dispatch settings.
func (a *App) SaveAfterSalesDispatchConfig(jsonData string) (bool, string) {
var payload map[string]interface{}
if err := json.Unmarshal([]byte(jsonData), &payload); err != nil {
return false, fmt.Sprintf("解析派单配置失败: %v", err)
}
result, err := a.postHelperJSON("/api/after-sales/dispatch/config", payload)
if err != nil {
return false, err.Error()
}
return helperResultOK(result)
}
// GetAfterSalesDispatchQueue returns pending after-sales issues with dispatch state.
func (a *App) GetAfterSalesDispatchQueue() interface{} {
result, err := a.getHelperJSON("/api/after-sales/dispatch/queue")
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// AssignAfterSalesIssue manually assigns an after-sales issue to an engineer.
func (a *App) AssignAfterSalesIssue(issueId string, engineerUserId string) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/dispatch/assign", map[string]interface{}{
"issueId": issueId,
"engineerUserId": engineerUserId,
})
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// NotifyAfterSalesEngineer sends one issue notification to its assigned engineer.
func (a *App) NotifyAfterSalesEngineer(issueId string) interface{} {
result, err := a.postHelperJSON("/api/after-sales/dispatch/notify", map[string]interface{}{"issueId": issueId})
if err != nil {
if result != nil {
return result
}
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// BatchNotifyAfterSalesEngineers sends multiple issue notifications.
func (a *App) BatchNotifyAfterSalesEngineers(issueIds []string) interface{} {
result, err := a.postHelperJSON("/api/after-sales/dispatch/batch-notify", map[string]interface{}{"issueIds": issueIds})
if err != nil {
if result != nil {
return result
}
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}

1138
app.go Normal file

File diff suppressed because it is too large Load Diff

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View File

@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

63
build/darwin/Info.plist Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>

BIN
build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

15
build/windows/info.json Normal file
View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@@ -0,0 +1,175 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!define REQUEST_EXECUTION_LEVEL "user"
!define PRODUCT_EXECUTABLE "qiweimanager.exe"
!include "wails_tools.nsh"
!macro qiwei.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro qiwei.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKCU "${UNINST_KEY}"
!macroend
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "SimpChinese" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$LOCALAPPDATA\Programs\QiweiManager" # Current-user writable install folder.
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
SetOutPath $INSTDIR
Delete "$INSTDIR\helper_auto_reply.exe"
CreateDirectory "$INSTDIR\Log"
CreateDirectory "$INSTDIR\operations"
!insertmacro wails.webview2runtime
!insertmacro wails.files
File "runtime\helper.exe"
File "runtime\Helper_*.dll"
File "runtime\Loader_*.dll"
SetOutPath "$INSTDIR\requestdata"
File /r "runtime\requestdata\*.*"
SetOutPath "$INSTDIR\eventdata"
File /r "runtime\eventdata\*.*"
CreateDirectory "$INSTDIR\config"
IfFileExists "$INSTDIR\config\config.json" config_done 0
SetOutPath "$INSTDIR\config"
File "runtime\config\config.json"
config_done:
IfFileExists "$INSTDIR\config\client_status.json" client_status_done 0
SetOutPath "$INSTDIR\config"
File "runtime\config\client_status.json"
client_status_done:
CreateDirectory "$INSTDIR\config\knowledge"
IfFileExists "$INSTDIR\config\knowledge\.keep" knowledge_keep_done 0
SetOutPath "$INSTDIR\config\knowledge"
File "runtime\config\knowledge\.keep"
knowledge_keep_done:
CreateDirectory "$INSTDIR\config\materials"
IfFileExists "$INSTDIR\config\materials\materials.json" materials_done 0
SetOutPath "$INSTDIR\config\materials"
File "runtime\config\materials\materials.json"
materials_done:
SetOutPath $INSTDIR
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro qiwei.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro qiwei.deleteUninstaller
SectionEnd

View File

@@ -0,0 +1,236 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "qiweimanager"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "灵泽万川"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "灵泽万川企微售后客服"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "2.1.3"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "Copyright © 灵泽万川"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
!macroend

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

36
build_helper.bat Normal file
View File

@@ -0,0 +1,36 @@
@echo off
REM 删除已存在的helper_32bit.exe文件
echo Removing existing helper.exe...
if exist build\bin\helper.exe del /F build\bin\helper.exe
REM Build 32-bit helper application as GUI (no console window)
echo Building 32-bit helper application as GUI...
set "GOARCH=386"
cd helper && go build -ldflags="-H windowsgui -s -w" -o ..\build\bin\helper.exe . && cd ..
REM Check build success
if %ERRORLEVEL% EQU 0 (
echo Helper build successful!
) else (
echo Helper build failed!
pause
exit /b 1
)
REM Ensure build directory exists
if not exist build\bin mkdir build\bin
REM Copy DLL files if they exist
if exist Helper_4.1.33.6009.dll (
copy /Y Helper_4.1.33.6009.dll build\bin\ >nul
echo DLL files copied to build directory
)
if exist Loader_4.1.33.6009.dll (
copy /Y Loader_4.1.33.6009.dll build\bin\ >nul
echo DLL files copied to build directory
)
echo All operations completed!
pause

12
config/config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"callbackConfig": {
"callbackUrl": "",
"callbackToken": "",
"httpPort": "10001",
"enableCallback": false,
"enableCloudAuth": false,
"fileUploadUrl": "",
"deviceCode": ""
},
"lastUpdated": 1756791901
}

177
config/config_manager.go Normal file
View File

@@ -0,0 +1,177 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"time"
"qiweimanager/logger"
)
var (
// 全局配置管理器实例
globalConfigManager *ConfigManager
// 全局配置实例
globalConfig *Config
)
// ConfigManager 配置管理器,负责加载和保存配置
type ConfigManager struct {
configFilePath string
logger *logger.Logger
}
// NewConfigManager 创建配置管理器实例
func NewConfigManager(appName string, logger *logger.Logger) (*ConfigManager, error) {
// 获取可执行文件路径
exePath, err := os.Executable()
if err != nil {
// 如果无法获取可执行文件路径,使用当前工作目录作为备选
configDir, err := os.Getwd()
if err != nil {
return nil, err
}
// 创建配置目录
appConfigDir := filepath.Join(configDir, "config")
if err := os.MkdirAll(appConfigDir, 0755); err != nil {
return nil, err
}
// 构建配置文件完整路径
configFilePath := filepath.Join(appConfigDir, "config.json")
return &ConfigManager{
configFilePath: configFilePath,
logger: logger,
},
nil
}
// 获取可执行文件所在目录
exeDir := filepath.Dir(exePath)
// 创建应用程序配置目录位于可执行文件同级的config目录
appConfigDir := filepath.Join(exeDir, "config")
if err := os.MkdirAll(appConfigDir, 0755); err != nil {
return nil, err
}
// 构建配置文件完整路径
configFilePath := filepath.Join(appConfigDir, "config.json")
return &ConfigManager{
configFilePath: configFilePath,
logger: logger,
}, nil
}
// LoadConfig 从文件加载配置
func (cm *ConfigManager) LoadConfig() (*Config, error) {
// 检查配置文件是否存在
if _, err := os.Stat(cm.configFilePath); os.IsNotExist(err) {
cm.logger.Info("配置文件不存在,使用默认配置: %s", cm.configFilePath)
// 返回默认配置
return NewDefaultConfig(), nil
}
// 读取配置文件内容
configData, err := os.ReadFile(cm.configFilePath)
if err != nil {
cm.logger.Error("读取配置文件失败: %v", err)
return nil, err
}
// 解析配置文件
config := &Config{}
if err := json.Unmarshal(configData, config); err != nil {
cm.logger.Error("解析配置文件失败: %v", err)
// 解析失败时返回默认配置
return NewDefaultConfig(), nil
}
config.ApplyDefaults()
cm.logger.Info("配置文件加载成功: %s", cm.configFilePath)
return config, nil
}
// SaveConfig 将配置保存到文件
func (cm *ConfigManager) SaveConfig(config *Config) error {
config.ApplyDefaults()
// 更新最后修改时间
config.LastUpdated = time.Now().Unix()
// 将配置转换为JSON
configData, err := json.MarshalIndent(config, "", " ")
if err != nil {
cm.logger.Error("序列化配置失败: %v", err)
return err
}
// 写入配置文件
if err := os.WriteFile(cm.configFilePath, configData, 0644); err != nil {
cm.logger.Error("写入配置文件失败: %v", err)
return err
}
cm.logger.Info("配置文件保存成功: %s", cm.configFilePath)
return nil
}
// InitGlobalConfig 初始化全局配置
func InitGlobalConfig(appName string, logger *logger.Logger) error {
var err error
globalConfigManager, err = NewConfigManager(appName, logger)
if err != nil {
return err
}
// 加载配置
globalConfig, err = globalConfigManager.LoadConfig()
return err
}
// GetGlobalConfig 获取全局配置实例
func GetGlobalConfig() *Config {
return globalConfig
}
// SaveGlobalConfig 保存全局配置
func SaveGlobalConfig() error {
if globalConfigManager != nil && globalConfig != nil {
return globalConfigManager.SaveConfig(globalConfig)
}
return nil
}
// UpdateCallbackConfig 更新回调配置
func UpdateCallbackConfig(callbackConfig CallbackConfig) error {
if globalConfig != nil {
globalConfig.CallbackConfig = callbackConfig
return SaveGlobalConfig()
}
return nil
}
// UpdateAutoReplyConfig updates automatic customer-service settings.
func UpdateAutoReplyConfig(autoReplyConfig AutoReplyConfig) error {
if globalConfig != nil {
globalConfig.AutoReplyConfig = autoReplyConfig
globalConfig.ApplyDefaults()
return SaveGlobalConfig()
}
return nil
}
// ReloadGlobalConfig reloads config.json from disk.
func ReloadGlobalConfig() (*Config, error) {
if globalConfigManager == nil {
return nil, nil
}
cfg, err := globalConfigManager.LoadConfig()
if err != nil {
return nil, err
}
cfg.ApplyDefaults()
globalConfig = cfg
return globalConfig, nil
}

View File

@@ -0,0 +1,169 @@
# Bug修复知识库模型配置错误
## 问题描述
客户报告了以下问题:
1. **知识库显示10个文件但查询时只返回2个文件**
2. **第四张图报错**`HTTP状态码错误: 404, body={"error":{"message":"Unsupported model 'gte-rerank-v2' for OpenAI compatibility mode","type":"invalid_request_error","param":null,"code":"model_not_supported"}}`
## 根本原因
客户**错误地将 Rerank 模型名称填到了 Embedding 模型字段**
- **Embedding 模型**字段填写了:`gte-rerank-v2`这是一个Rerank模型
- **Rerank 模型**字段填写了:`qwen3-rerank`(正确)
正确的配置应该是:
- **Embedding 模型**`text-embedding-v4``text-embedding-v3` 等向量化模型
- **Rerank 模型**`qwen3-rerank``gte-rerank-v2` 等重排序模型
### 为什么会导致只返回2个文件
1. 系统在重建知识库索引时调用 Embedding API 失败(因为 `gte-rerank-v2` 不是有效的 Embedding 模型)
2. 向量索引为空或不完整
3. 查询时向量检索失败,系统降级使用关键词检索
4. Rerank 步骤也受到影响,最终只返回了部分结果
## 修复方案
### 1. 后端配置验证 (config/types.go)
添加了两个验证函数:
- `isRerankModelName()` - 识别 Rerank 模型名称
- `isEmbeddingModelName()` - 识别 Embedding 模型名称
`ApplyDefaults()` 函数中自动检测和修正错误配置:
```go
// 检测用户是否错误地将 Rerank 模型填到了 Embedding 模型字段
if isRerankModelName(c.AutoReplyConfig.Retrieval.EmbeddingModel) {
c.AutoReplyConfig.Retrieval.EmbeddingModel = defaultAuto.Retrieval.EmbeddingModel
}
// 检测用户是否错误地将 Embedding 模型填到了 Rerank 模型字段
if isEmbeddingModelName(c.AutoReplyConfig.Retrieval.RerankModel) {
c.AutoReplyConfig.Retrieval.RerankModel = defaultAuto.Retrieval.RerankModel
}
```
### 2. 改进错误提示 (helper/auto_reply_retrieval.go)
`callDashScopeEmbeddings()` 函数中添加更友好的错误信息:
```go
if strings.Contains(strings.ToLower(errMsg), "unsupported model") &&
strings.Contains(strings.ToLower(errMsg), "rerank") {
return nil, fmt.Errorf("Embedding模型配置错误'%s' 是一个Rerank模型不是Embedding模型。请使用 text-embedding-v4 或 text-embedding-v3 等Embedding模型", retrievalCfg.EmbeddingModel)
}
```
### 3. 前端UI优化 (frontend/src/components/AutoReply.vue)
#### 3.1 添加说明文本
在模型配置输入框下方添加提示:
```vue
<label>
<span>Embedding 模型</span>
<input v-model="form.retrieval.embeddingModel" placeholder="text-embedding-v4">
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
用于文本向量化例如text-embedding-v4, text-embedding-v3
</small>
</label>
<label>
<span>Rerank 模型</span>
<input v-model="form.retrieval.rerankModel" placeholder="qwen3-rerank">
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
用于结果重排序例如qwen3-rerank, gte-rerank-v2
</small>
</label>
```
#### 3.2 添加前端验证
在保存配置前进行验证:
```javascript
function validateModelConfig() {
if (!form.retrieval) return null
const embeddingModel = String(form.retrieval.embeddingModel || '').trim().toLowerCase()
const rerankModel = String(form.retrieval.rerankModel || '').trim().toLowerCase()
// 检测 Embedding 模型字段是否填写了 Rerank 模型
if (embeddingModel && (embeddingModel.includes('rerank') || ...)) {
return `配置错误Embedding 模型字段不能填写 Rerank 模型...`
}
// 检测 Rerank 模型字段是否填写了 Embedding 模型
if (rerankModel && (rerankModel.includes('embedding') || ...)) {
return `配置错误Rerank 模型字段不能填写 Embedding 模型...`
}
return null
}
```
## 测试验证
创建了完整的单元测试 (`types_test.go`)
- `TestIsRerankModelName` - 验证 Rerank 模型识别
- `TestIsEmbeddingModelName` - 验证 Embedding 模型识别
- `TestApplyDefaultsFixesWrongModelConfig` - 验证自动修正功能
- `TestApplyDefaultsFixesWrongRerankConfig` - 验证反向错误修正
所有测试通过✓
## 使用指南
### 正确的模型配置
#### Embedding 模型(用于文本向量化)
- `text-embedding-v4`
- `text-embedding-v3`
- `bge-large-zh`
- `gte-large`
#### Rerank 模型(用于结果重排序)
- `qwen3-rerank`
- `gte-rerank-v2`
- `bge-rerank-large`
### 常见错误配置
**错误示例**
```json
{
"embeddingModel": "gte-rerank-v2", // 错误这是Rerank模型
"rerankModel": "qwen3-rerank"
}
```
**正确配置**
```json
{
"embeddingModel": "text-embedding-v4", // 正确Embedding模型
"rerankModel": "qwen3-rerank" // 正确Rerank模型
}
```
## 如何告知客户
1. 更新到新版本后,系统会自动检测并修正错误的模型配置
2. 如果配置被自动修正,建议在"知识库"模块点击"重建索引"按钮
3. 新版本的UI会显示每个模型字段的用途和示例避免混淆
## 影响范围
- 影响范围:使用混合检索模式的所有用户
- 严重程度:高(导致知识库检索失败)
- 修复方式:自动修正 + 用户友好提示
- 兼容性:向后兼容,不影响正确配置的用户
## 相关文件
- `config/types.go` - 配置验证逻辑
- `helper/auto_reply_retrieval.go` - 错误提示改进
- `frontend/src/components/AutoReply.vue` - 前端UI和验证
- `types_test.go` - 单元测试

View File

@@ -0,0 +1,149 @@
# Bug修复总结
## 客户问题
客户反馈:"我搞了10个MD文件进去自动客服页面也显示有10个文件但是一回到会话一问就出现只有两个怎么回事而且第四张图报错如图"
## Bug分析
### 表面现象
1. 知识库页面显示10个文件389个片段 ✓
2. AI查询时只能看到2个文件 ❌
3. HTTP 404错误`Unsupported model 'gte-rerank-v2' for OpenAI compatibility mode`
### 根本原因
**客户将Rerank模型名称错误填到了Embedding模型字段**
从截图第四张可以看到客户的配置:
- Embedding 模型:`gte-rerank-v2`**错误这是Rerank模型**
- Rerank 模型:`qwen3-rerank` ← 正确
正确的应该是:
- Embedding 模型:`text-embedding-v4``text-embedding-v3`
- Rerank 模型:`gte-rerank-v2``qwen3-rerank`
### 错误传播链
```
错误配置 (gte-rerank-v2 作为Embedding)
Embedding API调用失败 (404: model not supported)
向量索引构建失败/不完整
查询时向量检索失败
系统降级为关键词检索
Rerank步骤受影响
只返回部分结果2个文件
```
## 修复方案
### 1. 后端自动修正 (config/types.go)
- 新增 `isRerankModelName()` 函数识别Rerank模型
- 新增 `isEmbeddingModelName()` 函数识别Embedding模型
-`ApplyDefaults()` 中自动检测并修正错误配置
### 2. 错误提示改进 (helper/auto_reply_retrieval.go)
-`callDashScopeEmbeddings()` 中增加友好的错误提示
- 明确告知用户配置了哪个错误的模型
- 提示正确的模型选择
### 3. 前端UI优化 (frontend/src/components/AutoReply.vue)
- 添加说明文字:"用于文本向量化例如text-embedding-v4, text-embedding-v3"
- 添加说明文字:"用于结果重排序例如qwen3-rerank, gte-rerank-v2"
- 新增 `validateModelConfig()` 函数在保存前验证配置
- 如果配置错误,保存时会显示友好的错误提示
### 4. 测试覆盖 (types_test.go)
- 测试Rerank模型识别准确性
- 测试Embedding模型识别准确性
- 测试自动修正功能
- 所有测试通过 ✓
## 修改的文件
| 文件 | 修改内容 | 行数 |
|------|---------|------|
| `config/types.go` | 添加模型验证和自动修正逻辑 | +40 |
| `helper/auto_reply_retrieval.go` | 改进错误提示信息 | +6 |
| `frontend/src/components/AutoReply.vue` | 添加UI提示和前端验证 | +30 |
| `types_test.go` | 添加单元测试 | +107 |
| `docs/BUG_FIX_MODEL_CONFIG.md` | 详细修复文档 | new |
| `docs/用户通知_知识库配置修复.md` | 用户通知文档 | new |
## 测试结果
```bash
$ go test -v -run "TestIs|TestApplyDefaults"
=== RUN TestIsRerankModelName
--- PASS: TestIsRerankModelName (0.00s)
=== RUN TestIsEmbeddingModelName
--- PASS: TestIsEmbeddingModelName (0.00s)
=== RUN TestApplyDefaultsFixesWrongModelConfig
--- PASS: TestApplyDefaultsFixesWrongModelConfig (0.00s)
=== RUN TestApplyDefaultsFixesWrongRerankConfig
--- PASS: TestApplyDefaultsFixesWrongRerankConfig (0.00s)
PASS
ok qiweimanager/config 0.098s
```
## 用户操作指南
### 立即解决方案
告知客户:
1. 打开"自动客服"→"知识库"模块
2. 将"Embedding 模型"从 `gte-rerank-v2` 改为 `text-embedding-v4`
3. 点击"保存配置"
4. 点击"重建索引"按钮
5. 等待重建完成1-3分钟
6. 测试查询:"现在知识库有哪些文件"
### 长期解决方案
1. 发布包含修复的新版本
2. 系统会自动检测并修正错误配置
3. 用户界面会显示清晰的说明文字
4. 保存时会进行验证,防止再次配置错误
## 预防措施
### 已实施
✅ 自动检测和修正错误配置
✅ 更友好的错误提示
✅ UI添加说明文字和示例
✅ 保存前验证配置
### 建议补充
- [ ] 在文档中明确说明两种模型的区别
- [ ] 在配置页面添加"推荐配置"按钮
- [ ] 添加配置检查工具,一键诊断配置问题
## 影响评估
### 影响范围
- 所有使用"混合检索 + 重排序"模式的用户
- 估计影响5-10%的用户(基于此为配置错误)
### 严重程度
- **高** - 导致知识库检索功能完全失效
### 修复效果
- ✅ 后端自动修正:错误配置会被自动检测和修正
- ✅ 前端验证:保存前阻止错误配置
- ✅ 友好提示:明确告知用户问题和解决方案
- ✅ 向后兼容:不影响配置正确的用户
## 经验教训
1. **UI设计要明确**:技术术语需要配合说明文字和示例
2. **配置验证很重要**:关键配置应在前后端都进行验证
3. **错误提示要友好**:技术错误信息要转化为用户能理解的语言
4. **自动修复优于报错**:能自动修正的就不要让用户手动修改
## 后续优化建议
1. 添加配置模板功能(一键应用推荐配置)
2. 添加配置导入/导出功能
3. 在UI中添加"配置检查"按钮,诊断常见问题
4. 改进日志输出,更容易定位配置问题

View File

@@ -0,0 +1,101 @@
# 知识库配置修复通知
## 问题说明
如果您遇到以下情况:
- ✅ 知识库文件显示10个但查询时AI只能看到2个
- ✅ 知识库页面出现错误:`Unsupported model 'gte-rerank-v2' for OpenAI compatibility mode`
**这是因为模型配置填写错误导致的。**
## 问题原因
**Embedding 模型****Rerank 模型** 填写混淆了!
### 错误配置示例 ❌
```
Embedding 模型: gte-rerank-v2 ← 这是Rerank模型填错了
Rerank 模型: qwen3-rerank ← 正确
```
### 正确配置示例 ✓
```
Embedding 模型: text-embedding-v4 ← 正确:用于文本向量化
Rerank 模型: qwen3-rerank ← 正确:用于结果重排序
```
## 如何修复
### 方法1自动修复推荐
更新到最新版本后:
1. 系统会自动检测并修正错误配置
2. 打开"自动客服"页面
3. 进入"知识库"模块
4. 点击"重建索引"按钮
5. 等待重建完成(可能需要几分钟)
### 方法2手动修复
1. 打开"自动客服"页面
2. 进入"知识库"模块
3. 找到模型配置区域
4.**Embedding 模型** 改为:`text-embedding-v4`
5.**Rerank 模型** 改为:`qwen3-rerank`如果之前填写的是embedding模型
6. 点击"保存配置"
7. 点击"重建索引"
## 模型选择指南
### Embedding 模型(文本向量化)
推荐使用以下模型之一:
- `text-embedding-v4` 推荐512维
- `text-embedding-v3` 1024维
- `bge-large-zh`
- `gte-large`
### Rerank 模型(结果重排序)
推荐使用以下模型之一:
- `qwen3-rerank` (推荐)
- `gte-rerank-v2`
- `bge-rerank-large`
## 新版本改进
最新版本包含以下改进:
1. ✨ 自动检测并修正错误的模型配置
2. ✨ 更友好的错误提示信息
3. ✨ 输入框下方添加了说明文字和示例
4. ✨ 保存前会验证配置是否正确
## 注意事项
⚠️ **不要将模型名称混淆**
- Embedding 模型通常包含 "embedding" 关键词
- Rerank 模型通常包含 "rerank" 关键词
⚠️ **重建索引需要时间**
- 10个MD文件大约需要1-3分钟
- 重建期间不影响正常使用
- 重建完成后查询结果会更准确
## 验证修复
修复后,可以通过以下方式验证:
1. 查看"知识库"模块的状态
- 知识文件数量应显示正确10个
- 知识片段数量应显示正确389个
- 向量片段数量应与知识片段相同或接近
2. 测试查询
- 询问:"现在知识库有哪些文件"
- AI应该能列出所有10个文件
- 不应再出现404错误
## 技术支持
如果按照以上步骤操作后问题仍未解决,请联系技术支持并提供:
1. 错误截图
2. 当前的模型配置截图
3. 日志文件Log目录下的最新日志

1
config/knowledge/.keep Normal file
View File

@@ -0,0 +1 @@
Keep this directory for automatic customer-service knowledge files.

View File

@@ -0,0 +1,69 @@
{
"materials": [
{
"id": "cat-image",
"title": "猫猫图片",
"keywords": [
"猫猫图片",
"图片",
"看图",
"示意图"
],
"questionPatterns": [
"有没有图片",
"发图片",
"看一下图片",
"图示"
],
"materialType": "image",
"path": "猫猫图片.jpg",
"caption": "我把图片发你。",
"priority": 3,
"enabled": true
},
{
"id": "after-sales-sheet",
"title": "售后问题库",
"keywords": [
"售后问题库",
"问题库",
"售后表",
"处理表",
"问题清单"
],
"questionPatterns": [
"有没有问题表",
"发我问题表",
"售后问题怎么处理",
"处理流程"
],
"materialType": "file",
"path": "售后问题库_2026-05-30_1629.xlsx",
"caption": "我把售后问题表发你。",
"priority": 2,
"enabled": true
},
{
"id": "scheme-template",
"title": "方案模板",
"keywords": [
"方案模板",
"方案",
"模板",
"文档",
"资料"
],
"questionPatterns": [
"发我方案",
"有没有方案",
"发个模板",
"给我文档"
],
"materialType": "file",
"path": "方案模板.docx",
"caption": "我把方案模板发你。",
"priority": 2,
"enabled": true
}
]
}

560
config/types.go Normal file
View File

@@ -0,0 +1,560 @@
package config
import (
"strings"
"time"
)
const defaultVisionModel = "qwen3-vl-plus"
const defaultAISystemPrompt = "你是一名企业微信智能客服。"
// CallbackConfig stores local callback and helper API settings.
type CallbackConfig struct {
CallbackURL string `json:"callbackUrl"`
CallbackToken string `json:"callbackToken"`
HTTPPort string `json:"httpPort"`
EnableCallback bool `json:"enableCallback"`
EnableCloudAuth bool `json:"enableCloudAuth"`
FileUploadUrl string `json:"fileUploadUrl"`
DeviceCode string `json:"deviceCode"`
}
// AutoReplyConfig stores the local automatic customer-service settings.
type AutoReplyConfig struct {
Enabled bool `json:"enabled"`
Listen ListenConfig `json:"listen"`
Knowledge KnowledgeConfig `json:"knowledge"`
Retrieval RetrievalConfig `json:"retrieval"`
AI AIConfig `json:"ai"`
Materials MaterialsConfig `json:"materials"`
HumanAssist HumanAssistConfig `json:"humanAssist"`
Collaboration CollaborationConfig `json:"collaboration"`
Handoff HandoffConfig `json:"handoff"`
Identity IdentityConfig `json:"identity"`
ReplyPolicy ReplyPolicyConfig `json:"replyPolicy"`
ReplyStyle string `json:"replyStyle"`
}
type ListenConfig struct {
EnablePrivateChat bool `json:"enablePrivateChat"`
EnableGroupChat bool `json:"enableGroupChat"`
GroupTriggerMode string `json:"groupTriggerMode"`
IgnoreSelfMessage bool `json:"ignoreSelfMessage"`
DeduplicateSeconds int `json:"deduplicateSeconds"`
}
type KnowledgeConfig struct {
Directory string `json:"directory"`
IndexPath string `json:"indexPath"`
SupportedExtensions []string `json:"supportedExtensions"`
TopK int `json:"topK"`
MinScore float64 `json:"minScore"`
AutoRebuildOnStart bool `json:"autoRebuildOnStart"`
}
type RetrievalConfig struct {
RetrievalMode string `json:"retrievalMode"`
EmbeddingIndexPath string `json:"embeddingIndexPath"`
EmbeddingModel string `json:"embeddingModel"`
EmbeddingDimensions int `json:"embeddingDimensions"`
RerankModel string `json:"rerankModel"`
RecallTopK int `json:"recallTopK"`
RerankTopK int `json:"rerankTopK"`
FinalTopK int `json:"finalTopK"`
}
type MaterialsConfig struct {
Directory string `json:"directory"`
IndexPath string `json:"indexPath"`
AutoSendEnabled bool `json:"autoSendEnabled"`
MaxPerReply int `json:"maxPerReply"`
}
type HumanAssistConfig struct {
Enabled bool `json:"enabled"`
WaitSeconds int `json:"waitSeconds"`
AfterHumanReplyDelaySeconds int `json:"afterHumanReplyDelaySeconds"`
SupplementMode string `json:"supplementMode"`
IgnoreLikelyAutoSentEcho bool `json:"ignoreLikelyAutoSentEcho"`
MinimumHumanReplyLengthRunes int `json:"minimumHumanReplyLengthRunes"`
}
type CollaborationConfig struct {
Enabled bool `json:"enabled"`
HumanWaitSeconds int `json:"humanWaitSeconds"`
AfterHumanReplyDelaySeconds int `json:"afterHumanReplyDelaySeconds"`
TakeoverIdleExitSeconds int `json:"takeoverIdleExitSeconds"`
SupplementTarget string `json:"supplementTarget"`
EngineerReturnPolicy string `json:"engineerReturnPolicy"`
}
type AIConfig struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
SystemPrompt string `json:"systemPrompt"`
VisionModel string `json:"visionModel"`
VisionBaseURL string `json:"visionBaseUrl"`
VisionAPIKey string `json:"visionApiKey"`
AudioProvider string `json:"audioProvider"`
AudioMode string `json:"audioMode"`
AudioModel string `json:"audioModel"`
AudioBaseURL string `json:"audioBaseUrl"`
AudioAPIKey string `json:"audioApiKey"`
TimeoutSeconds int `json:"timeoutSeconds"`
EnableThinking bool `json:"enableThinking"`
ReplyDetail string `json:"replyDetail"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"maxTokens"`
}
type HandoffConfig struct {
HumanUserID string `json:"humanUserId"`
HumanConversationID string `json:"humanConversationId"`
MessageTemplate string `json:"messageTemplate"`
CustomerHandoffNotice string `json:"customerHandoffNotice"`
IncludeKnowledgeHits bool `json:"includeKnowledgeHits"`
SendHumanCardToCustomer bool `json:"sendHumanCardToCustomer"`
SendCustomerCardToHuman bool `json:"sendCustomerCardToHuman"`
CardTriggerMode string `json:"cardTriggerMode"`
ManualTriggerKeywords []string `json:"manualTriggerKeywords"`
CardKeywords []string `json:"cardKeywords"`
}
type IdentityConfig struct {
UnknownPolicy string `json:"unknownPolicy"`
UnknownHandoffPolicy string `json:"unknownHandoffPolicy"`
RefreshOnStart bool `json:"refreshOnStart"`
RefreshIntervalMinutes int `json:"refreshIntervalMinutes"`
PageSize int `json:"pageSize"`
InternalNoHandoffReply string `json:"internalNoHandoffReply"`
UnknownNoHandoffReply string `json:"unknownNoHandoffReply"`
InternalUserIDs []string `json:"internalUserIds"`
ExternalUserIDs []string `json:"externalUserIds"`
InternalGroupConversationIDs []string `json:"internalGroupConversationIds"`
InternalGroupIDsByScope map[string][]string `json:"internalGroupConversationIdsByScope"`
InternalUserLabels map[string]string `json:"internalUserLabels"`
ExternalUserLabels map[string]string `json:"externalUserLabels"`
}
type ReplyPolicyConfig struct {
UnknownAnswerToken string `json:"unknownAnswerToken"`
MaxQuestionLength int `json:"maxQuestionLength"`
CooldownSeconds int `json:"cooldownSeconds"`
SensitiveKeywords []string `json:"sensitiveKeywords"`
}
// Config stores the application configuration.
type Config struct {
CallbackConfig CallbackConfig `json:"callbackConfig"`
AutoReplyConfig AutoReplyConfig `json:"autoReplyConfig"`
LastUpdated int64 `json:"lastUpdated"`
}
// NewDefaultConfig creates a local-only default configuration.
func NewDefaultConfig() *Config {
return &Config{
CallbackConfig: CallbackConfig{
CallbackURL: "",
CallbackToken: "",
HTTPPort: "10001",
EnableCallback: false,
EnableCloudAuth: false,
FileUploadUrl: "",
DeviceCode: "",
},
AutoReplyConfig: NewDefaultAutoReplyConfig(),
LastUpdated: time.Now().Unix(),
}
}
// NewDefaultAutoReplyConfig creates disabled-but-ready automatic reply settings.
func NewDefaultAutoReplyConfig() AutoReplyConfig {
cfg := AutoReplyConfig{
Enabled: false,
Listen: ListenConfig{
EnablePrivateChat: true,
EnableGroupChat: true,
GroupTriggerMode: "mention_only",
IgnoreSelfMessage: true,
DeduplicateSeconds: 300,
},
Knowledge: KnowledgeConfig{
Directory: "config/knowledge",
IndexPath: "config/knowledge/index.json",
SupportedExtensions: []string{".md", ".txt", ".csv", ".xlsx", ".docx", ".pdf"},
TopK: 8,
MinScore: 0.40,
AutoRebuildOnStart: false,
},
Retrieval: RetrievalConfig{
RetrievalMode: "hybrid_rerank",
EmbeddingIndexPath: "config/knowledge/embedding_index.json",
EmbeddingModel: "text-embedding-v4",
EmbeddingDimensions: 512,
RerankModel: "qwen3-rerank",
RecallTopK: 50,
RerankTopK: 30,
FinalTopK: 8,
},
Materials: MaterialsConfig{
Directory: "config/materials",
IndexPath: "config/materials/materials.json",
AutoSendEnabled: true,
MaxPerReply: 2,
},
HumanAssist: HumanAssistConfig{
Enabled: false,
WaitSeconds: 15,
AfterHumanReplyDelaySeconds: 3,
SupplementMode: "supplement",
IgnoreLikelyAutoSentEcho: true,
MinimumHumanReplyLengthRunes: 4,
},
Collaboration: CollaborationConfig{
Enabled: false,
HumanWaitSeconds: 180,
AfterHumanReplyDelaySeconds: 3,
TakeoverIdleExitSeconds: 300,
SupplementTarget: "customer",
EngineerReturnPolicy: "review",
},
AI: AIConfig{
Provider: "openai_compatible",
BaseURL: "",
APIKey: "",
Model: "qwen-turbo",
SystemPrompt: defaultAISystemPrompt,
VisionModel: defaultVisionModel,
VisionBaseURL: "",
VisionAPIKey: "",
AudioProvider: "auto",
AudioMode: "openai_audio_chat",
AudioModel: "qwen3-asr-flash",
AudioBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
AudioAPIKey: "",
TimeoutSeconds: 20,
EnableThinking: false,
ReplyDetail: "detailed",
Temperature: 0,
MaxTokens: 700,
},
Handoff: HandoffConfig{
HumanUserID: "",
HumanConversationID: "",
MessageTemplate: "",
CustomerHandoffNotice: "已为您通知人工客服添加您的好友,请稍等。若 2 分钟内仍未收到好友申请,请点击上方名片主动添加人工客服。",
IncludeKnowledgeHits: true,
SendHumanCardToCustomer: true,
SendCustomerCardToHuman: true,
CardTriggerMode: "manual_keywords",
ManualTriggerKeywords: []string{"人工", "客服", "转人工", "人工客服", "真人", "真人客服"},
CardKeywords: []string{"人工", "客服", "转人工", "人工客服", "真人", "真人客服"},
},
Identity: IdentityConfig{
UnknownPolicy: "customer",
UnknownHandoffPolicy: "hold",
RefreshOnStart: true,
RefreshIntervalMinutes: 30,
PageSize: 200,
InternalNoHandoffReply: "内部员工消息不触发转人工,如需协助请直接联系对应同事。",
UnknownNoHandoffReply: "正在核验联系人身份,暂不触发转人工。如需协助请直接联系对应同事。",
InternalUserIDs: []string{},
ExternalUserIDs: []string{},
InternalGroupConversationIDs: []string{},
InternalGroupIDsByScope: map[string][]string{},
InternalUserLabels: map[string]string{},
ExternalUserLabels: map[string]string{},
},
ReplyPolicy: ReplyPolicyConfig{
UnknownAnswerToken: "NO_ANSWER",
MaxQuestionLength: 1000,
CooldownSeconds: 3,
SensitiveKeywords: []string{"人工", "转人工", "人工客服", "真人客服", "投诉", "退款", "退货", "合同", "发票", "赔偿", "价格审批"},
},
}
cfg.ReplyStyle = "natural_professional"
return cfg
}
// ApplyDefaults fills missing values for configs loaded from older files.
func (c *Config) ApplyDefaults() {
if c == nil {
return
}
defaultConfig := NewDefaultConfig()
if c.CallbackConfig.HTTPPort == "" {
c.CallbackConfig.HTTPPort = defaultConfig.CallbackConfig.HTTPPort
}
defaultAuto := NewDefaultAutoReplyConfig()
if c.AutoReplyConfig.Listen.GroupTriggerMode == "" {
c.AutoReplyConfig.Listen.GroupTriggerMode = defaultAuto.Listen.GroupTriggerMode
}
if !c.AutoReplyConfig.Listen.EnablePrivateChat && !c.AutoReplyConfig.Listen.EnableGroupChat {
c.AutoReplyConfig.Listen.EnablePrivateChat = defaultAuto.Listen.EnablePrivateChat
c.AutoReplyConfig.Listen.EnableGroupChat = defaultAuto.Listen.EnableGroupChat
}
if c.AutoReplyConfig.Listen.DeduplicateSeconds <= 0 {
c.AutoReplyConfig.Listen.DeduplicateSeconds = defaultAuto.Listen.DeduplicateSeconds
}
if c.AutoReplyConfig.Knowledge.Directory == "" {
c.AutoReplyConfig.Knowledge.Directory = defaultAuto.Knowledge.Directory
}
if c.AutoReplyConfig.Knowledge.IndexPath == "" {
c.AutoReplyConfig.Knowledge.IndexPath = defaultAuto.Knowledge.IndexPath
}
if len(c.AutoReplyConfig.Knowledge.SupportedExtensions) == 0 {
c.AutoReplyConfig.Knowledge.SupportedExtensions = defaultAuto.Knowledge.SupportedExtensions
}
if c.AutoReplyConfig.Knowledge.TopK <= 0 {
c.AutoReplyConfig.Knowledge.TopK = defaultAuto.Knowledge.TopK
} else if c.AutoReplyConfig.Knowledge.TopK < defaultAuto.Knowledge.TopK {
c.AutoReplyConfig.Knowledge.TopK = defaultAuto.Knowledge.TopK
}
if c.AutoReplyConfig.Knowledge.MinScore <= 0 {
c.AutoReplyConfig.Knowledge.MinScore = defaultAuto.Knowledge.MinScore
}
if c.AutoReplyConfig.Retrieval.RetrievalMode == "" {
c.AutoReplyConfig.Retrieval.RetrievalMode = defaultAuto.Retrieval.RetrievalMode
}
if c.AutoReplyConfig.Retrieval.EmbeddingIndexPath == "" {
c.AutoReplyConfig.Retrieval.EmbeddingIndexPath = defaultAuto.Retrieval.EmbeddingIndexPath
}
if c.AutoReplyConfig.Retrieval.EmbeddingModel == "" {
c.AutoReplyConfig.Retrieval.EmbeddingModel = defaultAuto.Retrieval.EmbeddingModel
}
// 检测用户是否错误地将 Rerank 模型填到了 Embedding 模型字段
if isRerankModelName(c.AutoReplyConfig.Retrieval.EmbeddingModel) {
c.AutoReplyConfig.Retrieval.EmbeddingModel = defaultAuto.Retrieval.EmbeddingModel
}
if c.AutoReplyConfig.Retrieval.EmbeddingDimensions <= 0 {
c.AutoReplyConfig.Retrieval.EmbeddingDimensions = defaultAuto.Retrieval.EmbeddingDimensions
}
if c.AutoReplyConfig.Retrieval.RerankModel == "" {
c.AutoReplyConfig.Retrieval.RerankModel = defaultAuto.Retrieval.RerankModel
}
// 检测用户是否错误地将 Embedding 模型填到了 Rerank 模型字段
if isEmbeddingModelName(c.AutoReplyConfig.Retrieval.RerankModel) {
c.AutoReplyConfig.Retrieval.RerankModel = defaultAuto.Retrieval.RerankModel
}
if c.AutoReplyConfig.Retrieval.RecallTopK <= 0 {
c.AutoReplyConfig.Retrieval.RecallTopK = defaultAuto.Retrieval.RecallTopK
} else if c.AutoReplyConfig.Retrieval.RecallTopK < defaultAuto.Retrieval.RecallTopK {
c.AutoReplyConfig.Retrieval.RecallTopK = defaultAuto.Retrieval.RecallTopK
}
if c.AutoReplyConfig.Retrieval.RerankTopK <= 0 {
c.AutoReplyConfig.Retrieval.RerankTopK = defaultAuto.Retrieval.RerankTopK
} else if c.AutoReplyConfig.Retrieval.RerankTopK < defaultAuto.Retrieval.RerankTopK {
c.AutoReplyConfig.Retrieval.RerankTopK = defaultAuto.Retrieval.RerankTopK
}
if c.AutoReplyConfig.Retrieval.FinalTopK <= 0 {
c.AutoReplyConfig.Retrieval.FinalTopK = defaultAuto.Retrieval.FinalTopK
} else if c.AutoReplyConfig.Retrieval.FinalTopK < defaultAuto.Retrieval.FinalTopK {
c.AutoReplyConfig.Retrieval.FinalTopK = defaultAuto.Retrieval.FinalTopK
}
if c.AutoReplyConfig.Materials.Directory == "" {
c.AutoReplyConfig.Materials.Directory = defaultAuto.Materials.Directory
}
if c.AutoReplyConfig.Materials.IndexPath == "" {
c.AutoReplyConfig.Materials.IndexPath = defaultAuto.Materials.IndexPath
}
if c.AutoReplyConfig.Materials.MaxPerReply <= 0 {
c.AutoReplyConfig.Materials.MaxPerReply = defaultAuto.Materials.MaxPerReply
}
if c.AutoReplyConfig.HumanAssist.WaitSeconds <= 0 {
c.AutoReplyConfig.HumanAssist.WaitSeconds = defaultAuto.HumanAssist.WaitSeconds
}
if c.AutoReplyConfig.HumanAssist.AfterHumanReplyDelaySeconds < 0 {
c.AutoReplyConfig.HumanAssist.AfterHumanReplyDelaySeconds = defaultAuto.HumanAssist.AfterHumanReplyDelaySeconds
}
if c.AutoReplyConfig.HumanAssist.SupplementMode == "" {
c.AutoReplyConfig.HumanAssist.SupplementMode = defaultAuto.HumanAssist.SupplementMode
}
if c.AutoReplyConfig.HumanAssist.MinimumHumanReplyLengthRunes <= 0 {
c.AutoReplyConfig.HumanAssist.MinimumHumanReplyLengthRunes = defaultAuto.HumanAssist.MinimumHumanReplyLengthRunes
}
if c.AutoReplyConfig.Collaboration.HumanWaitSeconds <= 0 {
c.AutoReplyConfig.Collaboration.HumanWaitSeconds = defaultAuto.Collaboration.HumanWaitSeconds
}
if c.AutoReplyConfig.Collaboration.AfterHumanReplyDelaySeconds < 0 {
c.AutoReplyConfig.Collaboration.AfterHumanReplyDelaySeconds = defaultAuto.Collaboration.AfterHumanReplyDelaySeconds
}
if c.AutoReplyConfig.Collaboration.TakeoverIdleExitSeconds <= 0 {
c.AutoReplyConfig.Collaboration.TakeoverIdleExitSeconds = defaultAuto.Collaboration.TakeoverIdleExitSeconds
}
if strings.TrimSpace(c.AutoReplyConfig.Collaboration.SupplementTarget) == "" {
c.AutoReplyConfig.Collaboration.SupplementTarget = defaultAuto.Collaboration.SupplementTarget
}
if strings.TrimSpace(c.AutoReplyConfig.Collaboration.EngineerReturnPolicy) == "" {
c.AutoReplyConfig.Collaboration.EngineerReturnPolicy = defaultAuto.Collaboration.EngineerReturnPolicy
}
if c.AutoReplyConfig.AI.Provider == "" {
c.AutoReplyConfig.AI.Provider = defaultAuto.AI.Provider
}
if c.AutoReplyConfig.AI.Model == "" {
c.AutoReplyConfig.AI.Model = defaultAuto.AI.Model
}
if strings.TrimSpace(c.AutoReplyConfig.AI.SystemPrompt) == "" {
c.AutoReplyConfig.AI.SystemPrompt = defaultAuto.AI.SystemPrompt
}
if c.AutoReplyConfig.AI.VisionModel == "" ||
(strings.EqualFold(c.AutoReplyConfig.AI.VisionModel, c.AutoReplyConfig.AI.Model) &&
!isVisionCapableModelName(c.AutoReplyConfig.AI.VisionModel)) ||
isLikelyTextOnlyQwenModel(c.AutoReplyConfig.AI.VisionModel) {
c.AutoReplyConfig.AI.VisionModel = defaultAuto.AI.VisionModel
}
if c.AutoReplyConfig.AI.AudioProvider == "" {
c.AutoReplyConfig.AI.AudioProvider = defaultAuto.AI.AudioProvider
}
if c.AutoReplyConfig.AI.AudioMode == "" {
c.AutoReplyConfig.AI.AudioMode = defaultAuto.AI.AudioMode
}
if c.AutoReplyConfig.AI.AudioModel == "" {
c.AutoReplyConfig.AI.AudioModel = defaultAuto.AI.AudioModel
}
if c.AutoReplyConfig.AI.TimeoutSeconds <= 0 {
c.AutoReplyConfig.AI.TimeoutSeconds = defaultAuto.AI.TimeoutSeconds
}
if c.AutoReplyConfig.AI.MaxTokens <= 0 {
c.AutoReplyConfig.AI.MaxTokens = defaultAuto.AI.MaxTokens
}
if c.AutoReplyConfig.AI.ReplyDetail == "" {
c.AutoReplyConfig.AI.ReplyDetail = defaultAuto.AI.ReplyDetail
}
if c.AutoReplyConfig.Handoff.MessageTemplate == "" {
c.AutoReplyConfig.Handoff.MessageTemplate = defaultAuto.Handoff.MessageTemplate
}
if c.AutoReplyConfig.Handoff.CustomerHandoffNotice == "" {
c.AutoReplyConfig.Handoff.CustomerHandoffNotice = defaultAuto.Handoff.CustomerHandoffNotice
}
if c.AutoReplyConfig.Handoff.CardTriggerMode == "" {
c.AutoReplyConfig.Handoff.SendHumanCardToCustomer = defaultAuto.Handoff.SendHumanCardToCustomer
c.AutoReplyConfig.Handoff.SendCustomerCardToHuman = defaultAuto.Handoff.SendCustomerCardToHuman
c.AutoReplyConfig.Handoff.CardTriggerMode = defaultAuto.Handoff.CardTriggerMode
}
if len(c.AutoReplyConfig.Handoff.ManualTriggerKeywords) == 0 {
c.AutoReplyConfig.Handoff.ManualTriggerKeywords = defaultAuto.Handoff.ManualTriggerKeywords
}
c.AutoReplyConfig.Handoff.ManualTriggerKeywords = dedupeStrings(append(
append([]string{}, c.AutoReplyConfig.Handoff.ManualTriggerKeywords...),
c.AutoReplyConfig.Handoff.CardKeywords...,
))
c.AutoReplyConfig.Handoff.CardKeywords = c.AutoReplyConfig.Handoff.ManualTriggerKeywords
if c.AutoReplyConfig.Identity.UnknownPolicy == "" {
c.AutoReplyConfig.Identity = defaultAuto.Identity
}
if c.AutoReplyConfig.Identity.UnknownHandoffPolicy == "" {
c.AutoReplyConfig.Identity.UnknownHandoffPolicy = defaultAuto.Identity.UnknownHandoffPolicy
}
if c.AutoReplyConfig.Identity.RefreshIntervalMinutes <= 0 {
c.AutoReplyConfig.Identity.RefreshIntervalMinutes = defaultAuto.Identity.RefreshIntervalMinutes
}
if c.AutoReplyConfig.Identity.PageSize <= 0 {
c.AutoReplyConfig.Identity.PageSize = defaultAuto.Identity.PageSize
}
if c.AutoReplyConfig.Identity.InternalNoHandoffReply == "" {
c.AutoReplyConfig.Identity.InternalNoHandoffReply = defaultAuto.Identity.InternalNoHandoffReply
}
if c.AutoReplyConfig.Identity.UnknownNoHandoffReply == "" {
c.AutoReplyConfig.Identity.UnknownNoHandoffReply = defaultAuto.Identity.UnknownNoHandoffReply
}
if c.AutoReplyConfig.Identity.InternalUserIDs == nil {
c.AutoReplyConfig.Identity.InternalUserIDs = defaultAuto.Identity.InternalUserIDs
}
if c.AutoReplyConfig.Identity.ExternalUserIDs == nil {
c.AutoReplyConfig.Identity.ExternalUserIDs = defaultAuto.Identity.ExternalUserIDs
}
if c.AutoReplyConfig.Identity.InternalGroupConversationIDs == nil {
c.AutoReplyConfig.Identity.InternalGroupConversationIDs = defaultAuto.Identity.InternalGroupConversationIDs
}
if c.AutoReplyConfig.Identity.InternalGroupIDsByScope == nil {
c.AutoReplyConfig.Identity.InternalGroupIDsByScope = defaultAuto.Identity.InternalGroupIDsByScope
}
if c.AutoReplyConfig.Identity.InternalUserLabels == nil {
c.AutoReplyConfig.Identity.InternalUserLabels = defaultAuto.Identity.InternalUserLabels
}
if c.AutoReplyConfig.Identity.ExternalUserLabels == nil {
c.AutoReplyConfig.Identity.ExternalUserLabels = defaultAuto.Identity.ExternalUserLabels
}
if c.AutoReplyConfig.ReplyPolicy.UnknownAnswerToken == "" {
c.AutoReplyConfig.ReplyPolicy.UnknownAnswerToken = defaultAuto.ReplyPolicy.UnknownAnswerToken
}
if c.AutoReplyConfig.ReplyPolicy.MaxQuestionLength <= 0 {
c.AutoReplyConfig.ReplyPolicy.MaxQuestionLength = defaultAuto.ReplyPolicy.MaxQuestionLength
}
if c.AutoReplyConfig.ReplyPolicy.CooldownSeconds <= 0 {
c.AutoReplyConfig.ReplyPolicy.CooldownSeconds = defaultAuto.ReplyPolicy.CooldownSeconds
}
if len(c.AutoReplyConfig.ReplyPolicy.SensitiveKeywords) == 0 {
c.AutoReplyConfig.ReplyPolicy.SensitiveKeywords = defaultAuto.ReplyPolicy.SensitiveKeywords
}
if strings.TrimSpace(c.AutoReplyConfig.ReplyStyle) == "" {
c.AutoReplyConfig.ReplyStyle = "natural_professional"
}
}
func dedupeStrings(items []string) []string {
seen := make(map[string]bool, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
result = append(result, item)
}
return result
}
func isVisionCapableModelName(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
return strings.Contains(name, "vl") ||
strings.Contains(name, "vision") ||
strings.Contains(name, "qvq") ||
strings.Contains(name, "omni")
}
func isLikelyTextOnlyQwenModel(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
if name == "" || isVisionCapableModelName(name) {
return false
}
switch name {
case "qwen-turbo", "qwen-plus", "qwen-max", "qwen-long":
return true
}
return strings.HasPrefix(name, "qwen") &&
(strings.Contains(name, "turbo") ||
strings.Contains(name, "plus") ||
strings.Contains(name, "max") ||
strings.Contains(name, "long") ||
strings.Contains(name, "coder") ||
strings.Contains(name, "math") ||
strings.Contains(name, "instruct"))
}
// isRerankModelName 检测模型名是否是 Rerank 模型
func isRerankModelName(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
if name == "" {
return false
}
return strings.Contains(name, "rerank") ||
strings.Contains(name, "gte-rerank") ||
strings.Contains(name, "bge-rerank")
}
// isEmbeddingModelName 检测模型名是否是 Embedding 模型
func isEmbeddingModelName(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
if name == "" {
return false
}
return strings.Contains(name, "embedding") ||
strings.Contains(name, "text-embedding") ||
strings.Contains(name, "bge-") ||
strings.Contains(name, "gte-") && !strings.Contains(name, "rerank")
}

101
config/types_test.go Normal file
View File

@@ -0,0 +1,101 @@
package config
import (
"testing"
)
// 注意:这些测试函数测试的是未导出的私有函数
// 如果编译失败,说明函数未定义或包名不匹配
func TestIsRerankModelName(t *testing.T) {
tests := []struct {
name string
model string
expected bool
}{
{"gte-rerank-v2", "gte-rerank-v2", true},
{"qwen3-rerank", "qwen3-rerank", true},
{"bge-rerank-large", "bge-rerank-large", true},
{"text-embedding-v4", "text-embedding-v4", false},
{"text-embedding-v3", "text-embedding-v3", false},
{"qwen-turbo", "qwen-turbo", false},
{"empty string", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isRerankModelName(tt.model)
if result != tt.expected {
t.Errorf("isRerankModelName(%q) = %v, want %v", tt.model, result, tt.expected)
}
})
}
}
func TestIsEmbeddingModelName(t *testing.T) {
tests := []struct {
name string
model string
expected bool
}{
{"text-embedding-v4", "text-embedding-v4", true},
{"text-embedding-v3", "text-embedding-v3", true},
{"bge-large-zh", "bge-large-zh", true},
{"gte-large", "gte-large", true},
{"gte-rerank-v2", "gte-rerank-v2", false},
{"qwen3-rerank", "qwen3-rerank", false},
{"qwen-turbo", "qwen-turbo", false},
{"empty string", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isEmbeddingModelName(tt.model)
if result != tt.expected {
t.Errorf("isEmbeddingModelName(%q) = %v, want %v", tt.model, result, tt.expected)
}
})
}
}
func TestApplyDefaultsFixesWrongModelConfig(t *testing.T) {
// 测试错误配置会被自动修正
cfg := NewDefaultConfig()
// 模拟用户错误地将 Rerank 模型填到 Embedding 字段
cfg.AutoReplyConfig.Retrieval.EmbeddingModel = "gte-rerank-v2"
cfg.AutoReplyConfig.Retrieval.RerankModel = "qwen3-rerank"
cfg.ApplyDefaults()
// 验证错误的 Embedding 模型被修正为默认值
if cfg.AutoReplyConfig.Retrieval.EmbeddingModel != "text-embedding-v4" {
t.Errorf("Expected embedding model to be corrected to 'text-embedding-v4', got %q", cfg.AutoReplyConfig.Retrieval.EmbeddingModel)
}
// 验证 Rerank 模型保持不变
if cfg.AutoReplyConfig.Retrieval.RerankModel != "qwen3-rerank" {
t.Errorf("Expected rerank model to remain 'qwen3-rerank', got %q", cfg.AutoReplyConfig.Retrieval.RerankModel)
}
}
func TestApplyDefaultsFixesWrongRerankConfig(t *testing.T) {
// 测试错误配置会被自动修正
cfg := NewDefaultConfig()
// 模拟用户错误地将 Embedding 模型填到 Rerank 字段
cfg.AutoReplyConfig.Retrieval.EmbeddingModel = "text-embedding-v4"
cfg.AutoReplyConfig.Retrieval.RerankModel = "text-embedding-v3"
cfg.ApplyDefaults()
// 验证 Embedding 模型保持不变
if cfg.AutoReplyConfig.Retrieval.EmbeddingModel != "text-embedding-v4" {
t.Errorf("Expected embedding model to remain 'text-embedding-v4', got %q", cfg.AutoReplyConfig.Retrieval.EmbeddingModel)
}
// 验证错误的 Rerank 模型被修正为默认值
if cfg.AutoReplyConfig.Retrieval.RerankModel != "qwen3-rerank" {
t.Errorf("Expected rerank model to be corrected to 'qwen3-rerank', got %q", cfg.AutoReplyConfig.Retrieval.RerankModel)
}
}

View File

@@ -0,0 +1,210 @@
# ========================================
# 企业微信更新禁用脚本 - 客户版
# 用途:阻止企业微信自动更新
# 使用方法:右键此文件 -> 以管理员身份运行
# ========================================
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "企业微信更新禁用工具" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 检查管理员权限
function Test-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-Admin)) {
Write-Host "错误:需要管理员权限!" -ForegroundColor Red
Write-Host "请右键此脚本,选择'以管理员身份运行'" -ForegroundColor Yellow
Write-Host ""
pause
exit 1
}
Write-Host "✓ 已获取管理员权限" -ForegroundColor Green
Write-Host ""
# ========================================
# 1. 禁用更新服务
# ========================================
Write-Host "[1/4] 正在禁用更新服务..." -ForegroundColor Yellow
$services = @(
"WXWorkUpgrader",
"WemeetUpdateSvc"
)
$serviceCount = 0
foreach ($serviceName in $services) {
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($service) {
try {
if ($service.Status -ne "Stopped") {
Stop-Service -Name $serviceName -Force -ErrorAction Stop
Write-Host " ✓ 已停止服务: $serviceName" -ForegroundColor Green
}
Set-Service -Name $serviceName -StartupType Disabled -ErrorAction Stop
Write-Host " ✓ 已禁用服务: $serviceName" -ForegroundColor Green
$serviceCount++
} catch {
Write-Host " ✗ 无法处理服务: $serviceName - $($_.Exception.Message)" -ForegroundColor Red
}
} else {
Write-Host " - 未找到服务: $serviceName" -ForegroundColor Gray
}
}
if ($serviceCount -eq 0) {
Write-Host " 未找到任何更新服务" -ForegroundColor Gray
}
Write-Host ""
# ========================================
# 2. 重命名更新程序
# ========================================
Write-Host "[2/4] 正在禁用更新程序..." -ForegroundColor Yellow
$wxworkPaths = @(
"C:\Program Files (x86)\WXWork",
"C:\Program Files\WXWork"
)
$renamedCount = 0
foreach ($basePath in $wxworkPaths) {
if (Test-Path -Path $basePath) {
Write-Host " 正在扫描: $basePath" -ForegroundColor Gray
$updaterFiles = Get-ChildItem -Path $basePath -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "^(WXWorkUpgrader|WemeetUpdateSvc|DeltaUpgradeHelper)\.exe$" }
foreach ($file in $updaterFiles) {
$disabledPath = "$($file.FullName).disabled"
try {
if (-not (Test-Path -LiteralPath $disabledPath)) {
Rename-Item -LiteralPath $file.FullName -NewName "$($file.Name).disabled" -Force -ErrorAction Stop
Write-Host " ✓ 已重命名: $($file.Name)" -ForegroundColor Green
$renamedCount++
} else {
Write-Host " - 已存在备份: $($file.Name).disabled" -ForegroundColor Gray
}
} catch {
Write-Host " ✗ 无法重命名: $($file.Name) - $($_.Exception.Message)" -ForegroundColor Red
}
}
}
}
if ($renamedCount -eq 0) {
Write-Host " 未找到需要重命名的更新程序" -ForegroundColor Gray
}
Write-Host ""
# ========================================
# 3. 添加防火墙规则
# ========================================
Write-Host "[3/4] 正在添加防火墙阻止规则..." -ForegroundColor Yellow
$firewallCount = 0
$updaterPatterns = @("WXWorkUpgrader.exe", "WemeetUpdateSvc.exe", "DeltaUpgradeHelper.exe")
foreach ($basePath in $wxworkPaths) {
if (Test-Path -Path $basePath) {
$updaterFiles = Get-ChildItem -Path $basePath -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "^(WXWorkUpgrader|WemeetUpdateSvc|DeltaUpgradeHelper)\.exe" }
foreach ($file in $updaterFiles) {
$ruleName = "阻止企业微信更新 - $($file.Name)"
if (-not (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue)) {
try {
New-NetFirewallRule -DisplayName $ruleName -Direction Outbound -Program $file.FullName -Action Block -Profile Any -ErrorAction Stop | Out-Null
Write-Host " ✓ 已添加防火墙规则: $($file.Name)" -ForegroundColor Green
$firewallCount++
} catch {
Write-Host " ✗ 无法添加防火墙规则: $($file.Name)" -ForegroundColor Red
}
} else {
Write-Host " - 防火墙规则已存在: $($file.Name)" -ForegroundColor Gray
}
}
}
}
if ($firewallCount -eq 0) {
Write-Host " 未添加新的防火墙规则" -ForegroundColor Gray
}
Write-Host ""
# ========================================
# 4. 禁用计划任务
# ========================================
Write-Host "[4/4] 正在禁用相关计划任务..." -ForegroundColor Yellow
$taskCount = 0
$tasks = Get-ScheduledTask -ErrorAction SilentlyContinue |
Where-Object {
$_.TaskName -match "WXWork|Wemeet|WeMeet|Tencent" -or
$_.TaskPath -match "WXWork|Wemeet|WeMeet|Tencent"
}
foreach ($task in $tasks) {
try {
Disable-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop | Out-Null
Write-Host " ✓ 已禁用计划任务: $($task.TaskName)" -ForegroundColor Green
$taskCount++
} catch {
Write-Host " ✗ 无法禁用计划任务: $($task.TaskName)" -ForegroundColor Red
}
}
if ($taskCount -eq 0) {
Write-Host " 未找到需要禁用的计划任务" -ForegroundColor Gray
}
Write-Host ""
# ========================================
# 显示当前状态
# ========================================
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "执行完成!当前状态:" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "--- 服务状态 ---" -ForegroundColor Yellow
$serviceStatus = Get-Service -Name WXWorkUpgrader,WemeetUpdateSvc -ErrorAction SilentlyContinue
if ($serviceStatus) {
$serviceStatus | Select-Object Name, Status, StartType | Format-Table -AutoSize
} else {
Write-Host "未找到更新服务" -ForegroundColor Gray
}
Write-Host ""
Write-Host "--- 更新程序文件 ---" -ForegroundColor Yellow
$allFiles = @()
foreach ($basePath in $wxworkPaths) {
if (Test-Path -Path $basePath) {
$files = Get-ChildItem -Path $basePath -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "(WXWorkUpgrader|WemeetUpdateSvc|DeltaUpgradeHelper)" } |
Select-Object -First 10
$allFiles += $files
}
}
if ($allFiles.Count -gt 0) {
$allFiles | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize
} else {
Write-Host "未找到更新程序文件(可能已被重命名)" -ForegroundColor Gray
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "操作完成!企业微信更新已被限制。" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
pause

44
eventdata/11026.json Normal file
View File

@@ -0,0 +1,44 @@
{
"data":{
"account":"xxxxxxx@qq.com",
"acctid":"Hoxxxxxui",
"avatar":"http:\/\/wework.qpic.cn\/bizmail\/IHgRKfv9SqpPJEZUfiaUfqMkckbS0ELewgZkibwCww80DM808qnyFVjQ\/0", // 头像
"corp_id":"1970325xxx44117", // 公司id
"document_root":"C:\\Users\\evilbeast\\Documents\\WXWork\\1688850xx9189", // 数据目录
"email":"4xxx236@qq.com",
"job_name":"",
"mobile":"手机号",
"nickname":"",
"pid":27536, // 进程id
"position":"",
"sex":1,
"user_id":"1688850xxxxx189", // 用户id
"username":"姓名"
},
"type":11026
}
{
"event": "20001",
"description": "用户登录成功通知事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"account": "{{data.account}}",
"acctid": "{{data.acctid}}",
"corpId": "{{data.corp_id}}",
"documentRoot": "{{data.document_root}}",
"jobName": "{{data.job_name}}",
"mobile": "{{data.mobile}}",
"iconUrl": "{{data.avatar}}",
"position": "{{data.position}}",
"avatarURL": "{{data.avatar}}",
"sex": "{{data.sex}}",
"email": "{{data.email}}",
"pid": "{{data.pid}}",
"userId": "{{data.user_id}}",
"username": "{{data.username}}",
"nickname": "{{data.nickname}}"
}
}

15
eventdata/11027.json Normal file
View File

@@ -0,0 +1,15 @@
{
"data":{
"user_id":"16888xxxxxx"
},
"type":11027
}
{
"event": "20009",
"description": "用户退出通知事件",
"time": 1726826627676,
"data": {
"instanceId": "string",
"robotId": "string"
}
}

21
eventdata/11028.json Normal file
View File

@@ -0,0 +1,21 @@
{
"data":{
"code":"https:\/\/wx.work.weixin.qq.com\/cgi-bin\/crtx_auth?key=AC217979C446C3F003BF4B12B4F7A8AA&wx=1",
"file":"C:\\Users\\evilbeast\\AppData\\Local\\Temp\\qr.png",
"key":"AC217979C446C3F003BF4B12B4F7A8AA"
},
"type":11028
}
{
"event": "20025",
"description": "登录二维码通知事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"qrUrl": "{{data.code}}",
"file": "{{data.file}}",
"key": "{{data.key}}"
}
}

41
eventdata/11041.json Normal file
View File

@@ -0,0 +1,41 @@
{
"data":{
"at_list":[],
"content":"hello world",
"content_type":2,
"conversation_id":"S:1688850xx328682_78813xxx83912998",
"is_pc":0,
"appinfo":"",
"local_id":"2819",
"receiver":"1688850535xxx",
"send_time":"1639407098",
"sender":"78813022839xx",
"sender_name":"小邪",
"server_id":"1099017"
},
"type":11041
}
{
"event": "20002",
"description": "文本消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"appInfo": "{{data.appinfo}}",
"messageType": 1,
"quoteAppInfo": "private",
"quoteMessage": "string",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"message": "{{data.content}}",
"isPc": 0,
"atWxIdList": "{{data.at_list}}"
}
}

66
eventdata/11042.json Normal file
View File

@@ -0,0 +1,66 @@
{
"data":{
"content_type":101,
"conversation_id":"S:16888xx682_788130228391xxx8",
"is_pc":0,
"local_id":"2820",
"receiver":"168885xx328682",
"send_time":"1639407252",
"sender":"788130xxx2998",
"sender_name":"小邪",
"server_id":"1099020",
"cdn_type": 1,
"cdn":{
"aes_key":"9c81aab279db832121a167dee95cadfe",
"file_id": "",
"file_name": "",
"auth_key":"v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2df624657529a8fdf135c2765fe8a71cdbd66c4600ae446d4099d53d0247dc7a70",
"ld_size":3113,
"ld_url":"https:\/\/imunion.weixin.qq.com\/cgi-bin\/mmae-bin\/tpdownloadmedia?param=v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2dc9c57df1d724e387e7cb93bc873bc0f28cfac9f4195e7ecf6bb9bffb63c4c7f01b1bc82cbb017a63a0403e60d3490d5ef6efb9505d087b9e0c9881a3981fe161933bbbbcfdbd265026c06dd7e991776ad0998a8ef5765d900c5dacd0a3064d1e4d20f1fccafc72f838501fdc0ef8ef0f54a7dad493ac1660b5a5c509c0e3b3138268b52caaf1462ee7cf1b2bce6dc0a49c572349b9535a387503871aedfc226f6906b71d06ce12b3580d38bd58b508599737e46ed3fc2f7fccfc1a1802b5370687bcf49736182e91df1172aac352e220",
"md5":"8884990c7174278280bc67037d1d3267",
"md_size":3281,
"md_url":"https:\/\/imunion.weixin.qq.com\/cgi-bin\/mmae-bin\/tpdownloadmedia?param=v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2dd755fc30a546c7050c628f0db8b92ae9172c4c8b52606d5c2725949fa945c3b7063306f0c2fe4c83c5e19126a8d5328447599cebc567ab8a550a437589b64dbc165a9c106b0fec80eeee00932045161fe31a935dbdb83204d214acab56063040d060ea3853d56882ee3d8d516a54a75c49b8825cb9f8373214774549d1a2ca0e2025102129951919bef1567414c70e7f08c0739c62f70c816b780e0082eb076d5e98360b7716b44b05c2b666f1f5e54cfe2dfea0dc56a7b0297f16799dd050de5d55d688229666a670538667972b84e4",
"size":18266,
"url":"https:\/\/imunion.weixin.qq.com\/cgi-bin\/mmae-bin\/tpdownloadmedia?param=v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2d4fed696ee3ec30cde3864d2d0bfa6432b31dc035d757af9b61055838044410ddaf3382de7252efc7478eff1e5bf9d8cfc0a5ffa6e94c275dcf411edac466c515d0de75d31276dd6242e523976e49e6698ec150e3dd1afdcc9a5d07befe2d7ae0684874b036046cd5d18ff9ab7d04443a90d9d901aa480853ff0b23ac1a9225fc3e352bf1508877667d0f71494e8791ac0b0394c04a16feab00a2d9274df7d3bfdafa336b438e88050934198fc72023a6e5531207aa66ff571f222c181f9f8caa4e526a6e93429f79e2b230cd3ad0cdfe"
}
},
"type":11042
}
{
"event": "20003",
"description": "图片消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "",
"robotId": "",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"appInfo": "string",
"messageType": 1,
"cdnData": {
"aes_key": "{{data.cdn.aes_key}}",
"auth_key": "{{data.cdn.auth_key}}",
"file_id": "{{data.cdn.file_id}}",
"file_name": "{{data.cdn.file_name}}",
"file_type":2,
"height": 120,
"is_hd": 0,
"ld_size": "{{data.cdn.ld_size}}",
"ld_url": "{{data.cdn.ld_url}}",
"md5": "{{data.cdn.md5}}",
"md_size": "{{data.cdn.md_size}}",
"md_url": "{{data.cdn.md_url}}",
"size": "{{data.cdn.size}}",
"url": "{{data.cdn.url}}",
"width": 0
},
"cdnType": "{{data.cdn_type}}",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc": 0
}
}

69
eventdata/11043.json Normal file
View File

@@ -0,0 +1,69 @@
{
"data":{
"content_type":103,
"conversation_id":"S:16888xx2_788130228xxx12998",
"duration":15,
"file_size":1043363,
"height":0,
"is_pc":0,
"local_id":"2824",
"receiver":"16888xx8682",
"send_time":"1639407800",
"sender":"7881302xx98",
"sender_name":"小邪",
"server_id":"1099032",
"width":0,
"cdn_type": 1,
"cdn":{
"aes_key":"1a7a19b30e56200d12a175be19de59cc",
"file_id":"",
"file_name":"",
"auth_key":"v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2de52564de133cf19ee5ea80775ceb70f7b139872dcfcc6ede751b9b2086b74f6c",
"md5":"ec8ea598dfbde57970f8018069ec378f",
"preview_img_size":2239,
"preview_img_url":"https:\/\/imunion.weixin.qq.com\/cgi-bin\/mmae-bin\/tpdownloadmedia?param=v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2d2ddab6162fdd3427e81a1ae5dbcad12358e9dec3778c65e55408d08440b03b0031850fc794ab3c68e2fef0df44e83dd8bd29f1e0f9e13e425a329b9bdfed8ed8c6800f8da16c219ceb69f037b35da7a2a47f723624e84acd57b058ad7648bcdd486e54bff62ed6fee3d44fe566207101c8f785e5bc7cdb600b7c157acb88bf158ae1792c2038d6155a929553fa352cc6cd5e2e42d4880dc814d231069e08f81fa6d108c4e568f72ba545765e46103eb5fa795a1158b0bada68a5483f8e7ae10f8999d2c1f119c9d2a1d082e3509cfb7f",
"size":1043363,
"url":"https:\/\/imunion.weixin.qq.com\/cgi-bin\/mmae-bin\/tpdownloadmedia?param=v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2d8e3bf620dba09b2d39c4a84d0c952b7a878df830c0208b23db99b4d8e2a09d32320fd4886a043754b037c25e0ad3b59a3a773148b11a8438f8f77893ea639a73de77e07fee289282ec47b209f7609d677df8245d9762662f863483b137bd82f85727d6e7d4d0e296d5192bac9fc6a20ed9b004827226fa51b9d2020c8a3f2254b6c4217de5aba7cba24a75c929ec900eb8ef34cb89360274dffcfc73d0d8979cc6b4f55b52bf96d3303a8d27fa6ff59dee27eafe9e259d80099450b6e1f89034fb4b16f0c4cbbf03e02059d11d75a219"
}
},
"type":11043
}
{
"event": "20004",
"description": "视频消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"appInfo": "string",
"messageType": 103,
"cdnData": {
"aes_key": "{{data.cdn.aes_key}}",
"auth_key": "{{data.cdn.auth_key}}",
"file_id": "{{data.cdn.file_id}}",
"file_name": "{{data.cdn.file_name}}",
"file_type":4,
"preview_img_size": "{{data.cdn.preview_img_size}}",
"preview_img_url": "{{data.cdn.preview_img_url}}",
"height": "{{data.height}}",
"md5": "{{data.cdn.md5}}",
"size": "{{data.cdn.size}}",
"url": "{{data.cdn.url}}",
"width": "{{data.width}}"
},
"cdnType": "{{data.cdn_type}}",
"height": "{{data.height}}",
"width": "{{data.width}}",
"duration": "{{data.duration}}",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc": "{{data.is_pc}}"
}
}

59
eventdata/11044.json Normal file
View File

@@ -0,0 +1,59 @@
{
"data":{
"cdn":{
"aes_key":"6D656B6E7275737A6677766C72667A6A",
"file_id":"308180020102047930770201000204af00072602031e903802045ec7f46d020461b762a6044c323933363031343633305f36443635364236453732373537333741363637373736364337323636374136415f34353737366337343265333431663732616333626434663937653065643362620201000202479004000201050201000400",
"file_name":"",
"md5":"45776c742e341f72ac3bd4f97e0ed3bb",
"size":18308,
"voice_time":10
},
"content_type":16,
"conversation_id":"S:168885xx328682_78813022xx998",
"duration":10,
"is_pc":0,
"local_id":"2827",
"receiver":"168885xx328682",
"send_time":"1639408294",
"sender":"7881302xx998",
"sender_name":"小邪",
"server_id":"1099043"
},
"type":11044
}
{
"event": "20012",
"description": "语音消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"messageType": 16,
"duration": "{{data.duration}}",
"voiceText": "{{data.voice_text}}",
"voiceToText": "{{data.voice_to_text}}",
"translateText": "{{data.translate_text}}",
"transText": "{{data.trans_text}}",
"transcript": "{{data.transcript}}",
"asrText": "{{data.asr_text}}",
"c2cCdnData": {
"aes_key": "{{data.cdn.aes_key}}",
"file_id": "{{data.cdn.file_id}}",
"file_name": "{{data.cdn.file_name}}",
"file_type":5,
"md5": "{{data.cdn.md5}}",
"size": "{{data.cdn.size}}",
"voice_time": "{{data.cdn.voice_time}}"
},
"appInfo": "",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0
}
}

55
eventdata/11045.json Normal file
View File

@@ -0,0 +1,55 @@
{
"data":{
"content_type":102,
"conversation_id":"S:1688850xx82_78813022xx998",
"is_pc":0,
"local_id":"2823",
"receiver":"16888xx328682",
"send_time":"1639407706",
"sender":"78813022xx2998",
"sender_name":"小邪",
"server_id":"1099029",
"cdn_type" : 1,
"cdn":{
"aes_key":"901e7f14fcd5edca41b63f88fabaf2e1",
"auth_key":"v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2d4dfcdfccb3fee1ae235af88d4b6b58235ad38d6ca1ebec83740747abeb9ba310",
"file_name":"HttpCanary.apk.1",
"file_id":"",
"md5":"59f9ca6f053d76afd17132b4f78c4c1b",
"size":9957248,
"url":"https:\/\/imunion.weixin.qq.com\/cgi-bin\/mmae-bin\/tpdownloadmedia?param=v1_84d696afdc73fbf997931c6eb898facc4830ae5cb891060fdd5cca540beccd2d43f1d5e6d03c22235d1882ffb730d6828bbc770e5c8762ae84b515c47c2e102d241c2eee181591b5762aef3806ebe71778f7b62f466439ddcd8519eb33b697024e856ac48f314f561544a7884f650494f3fcfbf1528c930f73832d32a2a4c5aa4e87856e7a53eb3a57d89d542331b7e056a97135232839442a2b7d0ff6f13e780efa77dd28c3bd12f67f5c5593762cec96865657bc6aeb0b44f6ea960257f428742c406fc2dcd767a63d04b7a01bd9eb7f099c74fea522160d8a77504b31906c2a574e2f66ecd6253dd29698732ebe4b"
}
},
"type":11045
}
{
"event": "20005",
"description": "文件消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"appInfo": "string",
"messageType": 103,
"cdnData": {
"aes_key": "{{data.cdn.aes_key}}",
"auth_key": "{{data.cdn.auth_key}}",
"file_id": "{{data.cdn.file_id}}",
"file_name": "{{data.cdn.file_name}}",
"file_type":5,
"md5": "{{data.cdn.md5}}",
"size": "{{data.cdn.size}}",
"url": "{{data.cdn.url}}"
},
"cdnType": "{{data.cdn_type}}",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc": "{{data.is_pc}}"
}
}

44
eventdata/11046.json Normal file
View File

@@ -0,0 +1,44 @@
{
"data":{
"address":"北京市东城区东长安街",
"content_type":6,
"conversation_id":"S:16888xxx328682_7881302xx12998",
"is_pc":0,
"latitude":39.903739928999997,
"local_id":"2828",
"longitude":116.397827148,
"receiver":"168885xxx28682",
"send_time":"1639408398",
"sender":"78813xx98",
"sender_name":"小邪",
"server_id":"1099046",
"title":"北京天安门广场",
"zoom":15
},
"type":11046
}
{
"event": "20013",
"description": "位置消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"messageType": 103,
"address": "{{data.address}}",
"latitude": "{{data.latitude}}",
"longitude": "{{data.longitude}}",
"title": "{{data.title}}",
"zoom": "{{data.zoom}}",
"appInfo": "",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0
}
}

56
eventdata/11047.json Normal file
View File

@@ -0,0 +1,56 @@
{
"data":{
"content_type":13,
"conversation_id":"S:168885xx8682_788130228xxx998",
"desc":"",
"image_url":"",
"is_pc":0,
"local_id":"2821",
"receiver":"1688850xxx8682",
"send_time":"1639407580",
"sender":"7881302283xxxx2998",
"sender_name":"小邪",
"server_id":"1099023",
"title":"据说思乡有八百多种,你是哪一种?",
"url":"http:\/\/mp.weixin.qq.com\/s?__biz=MTI0MDU3NDYwMQ==&mid=2657210171&idx=1&sn=ba8436a94914948d0ace62bc2d4fe101&chksm=7a5b32dd4d2cbbcbea2e14e0f067133ff0d74e7f87b5020e52cae84e06f8105f5c735879289f&scene=126&&sessionid=1639407574#rd",
"cdn_type" : 0
"cdn":{
"aes_key":"",
"auth_key":"",
"size":0,
"url":"https:\/\/mmbiz.qpic.cn\/mmbiz_jpg\/oq1PymRl9D58P1dxgJCW4ysxCGYWgVzMfaONib0w41Xib5JQ1iaWOgJKWLmNic1xVwCl2msJQic0tsS8kqUhPe3ftIA\/300?wxtype=jpeg&wxfrom=0"
}
},
"type":11047
}
{
"event": "20007",
"description": "链接消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"appInfo": "string",
"messageType": 103,
"cdnData": {
"aes_key": "{{data.cdn.aes_key}}",
"auth_key": "{{data.cdn.auth_key}}",
"size": "{{data.cdn.size}}",
"url": "{{data.cdn.url}}"
},
"cdnType":0,
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0,
"desc": "{{data.desc}}",
"imageUrl": "{{data.image_url}}",
"url": "{{data.url}}",
"title": "{{data.title}}"
}
}

49
eventdata/11048.json Normal file
View File

@@ -0,0 +1,49 @@
{
"data":{
"content_type":29,
"conversation_id":"S:1688850xx8682_78813xxx3912998",
"height":450,
"is_pc":0,
"local_id":"2822",
"name":"动画表情",
"receiver":"1688xxxxxx682",
"send_time":"1639407654",
"sender":"788130xxx98",
"sender_name":"小邪",
"server_id":"1099026",
"source_type":102,
"type":2,
"url":"https:\/\/wework.qpic.cn\/wwpic\/wwwx_f2e79eaafc0a12dc6e382e6ff361bffb\/0",
"width":600
},
"type":11048
}
{
"event": "20006",
"description": "表情消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"appInfo": "string",
"messageType": 103,
"cdnData": {
"aes_key": "",
"file_id": "string",
"md5": ""
},
"cdnType": 1,
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0,
"type":0,
"sourceType":0,
"url": "{{data.url}}"
}
}

41
eventdata/11049.json Normal file
View File

@@ -0,0 +1,41 @@
{
"data": {
"content_type": 26,
"conversation_id": "S:1688xxxx_16888505xxxx",
"desc": "来自xxxxx的红包请进入手机版企业微信查看",
"local_id": "385",
"money": 1,
"packet_id": "10000405012020xxxxxx",
"receiver": "1688850535834755",
"remark": "恭喜发财",
"send_time": "1580616846",
"sender": "168885053xxx",
"sender_name": "xxxxx",
"server_id": "100xxxxxxx"
},
"type": 11049
}
{
"event": "20011",
"description": "红包消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"messageType": 103,
"packetId": "{{data.packet_id}}",
"desc": "{{data.desc}}",
"remark": "{{data.remark}}",
"money": "{{data.money}}",
"appInfo": "",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc": 0
}
}

44
eventdata/11050.json Normal file
View File

@@ -0,0 +1,44 @@
{
"data":{
"avatar":"http:\/\/wx.qlogo.cn\/mmhead\/PiajxSqBRaEL1Inz8icSQgSJ4rV44UcaNWVPWGmiaTEhpfSdcxxTg\/0",
"content_type":41,
"conversation_id":"S:168885053xx682_7881299xx021868",
"is_pc":0,
"local_id":"2699",
"nickname":"贝朗", // 名称昵称
"receiver":"16888505xxx82",
"send_time":"1639318149",
"sender":"788129952xxx8",
"sender_name":"小邪",
"server_id":"1098527",
"source":"微信",
"user_id":"788129xxx2928"
},
"type":11050
}
{
"event": "20008",
"description": "名片消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"appInfo": "string",
"messageType": 103,
"avatarUrl": "{{data.avatar}}",
"corpId": 0,
"corpName": "",
"nickname": "{{data.nickname}}",
"userId": "{{data.user_id}}",
"source": "{{data.source}}",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0
}
}

27
eventdata/11063.json Normal file
View File

@@ -0,0 +1,27 @@
{
"data":{
"avatar":"http:\/\/wx.qlogo.cn\/mmhead\/UOCHvzUGAIU6r51scmeNibicnYs4KzTtIee8QY0MwIRfkukxxx6M1Gw\/0",
"corp_id":"1970325xxx26788",
"nickname":"小邪",
"sex":1,
"user_id":"7881302xxx998",
"verify":"我是小邪"
},
"type":11063
}
{
"event": "20017",
"description": "好友申请通知事件",
"time": "",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "",
"avatarUrl": "{{data.avatar}}",
"corpId": "{{data.corp_id}}",
"nickname": "{{data.nickname}}",
"sex": "{{data.sex}}",
"userId": "{{data.user_id}}",
"verify": "{{data.verify}}"
}
}

62
eventdata/11066.json Normal file
View File

@@ -0,0 +1,62 @@
{
"data":{
"appicon":"http:\/\/mmbiz.qpic.cn\/sz_mmbiz_png\/yLCrZFCQoB76U8YoQ6DPuCNGS4awVYOYGxEGnhRU7khicmowj4ty341Oydl5xE9uuoIl1wK7amDVQoQ79vkzO3g\/640?wx_fmt=png&wxfrom=200",
"appid":"wxe9714e742209d35f",
"appname":"唯品会特卖",
"content_type":78,
"conversation_id":"S:168885xxx8682_788130xxxx12998",
"is_pc":0,
"local_id":"2825",
"page_path":"pages\/index\/index.html?chl_type=share&chl_param=1627909475937_4a258ff09c0a487fa7f0815106273b4d%3A1639407978077%3APzonl",
"receiver":"168885xxxx82",
"send_time":"1639407981",
"sender":"78813xxxx12998",
"sender_name":"小邪",
"server_id":"1099035",
"thumb_width":100,
"thumb_height":100,
"title":"唯品会 · 品牌特卖 就是超值",
"username":"gh_8ed2afad9972@app"
"cdn_type": 2,
"cdn" : {
"aes_key":"6C7563736E666A6566696B6C716D6D65",
"file_id":"306b020102046430620201000204af00072602031e90380204ecc7f46d020461b7616d0436323933363031343633305f313832393236373539395f613037626133323031303431653363636535376161636531656633356334663802010002030160b004000201010201000400",
"size":90276,
}
},
"type":11066
}
{
"event": "20059",
"description": "小程序消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"messageType": 103,
"cdnData": {
"aes_key": "{{data.cdn.aes_key}}",
"file_id": "{{data.cdn.file_id}}",
"size": "{{data.cdn.size}}"
},
"cdnType":2,
"appIcon": "{{data.appicon}}",
"appId": "{{data.appid}}",
"appInfo": "",
"appName": "{{data.appname}}",
"pagePath": "{{data.page_path}}",
"thumbHeight": "{{data.thumb_height}}",
"thumbWidth": "{{data.thumb_width}}",
"title": "{{data.title}}",
"username": "{{data.username}}",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0
}
}

62
eventdata/11068.json Normal file
View File

@@ -0,0 +1,62 @@
{
"data":{
"content_type":123,
"conversation_id":"S:16888505xx8682_168885xx6004663",
"emotion_list":[],
"image_list":[{
"cdn_type": 2,
"cdn":{
"aes_key":"cce0ba73c7dd4ea3a799adb803c2a5d2",
"file_id":"3081b00201020481a83081a50201000204283cabaa02030f4df90204e5136071020461b5604c04694e45574944315f3238336361626161653531333630373136316237363336305f64656433663066312d386135312d346162352d383061622d6336353330643333316338355f66376166303563392d323932632d343562302d613434342d36663761323135636639636202010002030113200410e8873b0a25333bed1342112b1a2024a50201020201000400",
"file_name":"7b428249db04abc684953d4c5635e7d1.png",
"md5":"e8873b0a25333bed1342112b1a2024a5",
"size":70421
},
"file_size":70421
}],
"is_pc":0,
"local_id":"2829",
"receiver":"16888xx004663",
"send_time":"1639408480",
"sender":"16888505xx8682",
"sender_name":"小邪",
"server_id":"1099051",
"text_content":"你好图文消息"
},
"type":11068
}
{
"event": "20014",
"description": "图文消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"messageType": 103,
"textContent": "{{data.text_content}}",
"emotionList": "{{data.emotion_list}}",
"imageList": [
{
"cdn_type": "{{data.image_list[0].cdn_type}}",
"cdn": {
"aes_key": "{{data.image_list[0].cdn.aes_key}}",
"file_id": "{{data.image_list[0].cdn.file_id}}",
"file_name": "{{data.image_list[0].cdn.file_name}}",
"md5": "{{data.image_list[0].cdn.md5}}",
"size": "{{data.image_list[0].cdn.size}}"
},
"file_size": "{{data.image_list[0].file_size}}"
}
],
"appInfo": "",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0
}
}

33
eventdata/11072.json Normal file
View File

@@ -0,0 +1,33 @@
{
"data":{
"member_list":[{
"name":"liju1n",
"user_id":"78813xxx29115"
}],
"op_user_id":"16888505xx82",
"op_user_name":"小邪",
"room_conversation_id":"R:10696xxxxxx",
"room_name":"小邪、小邪、lijun"
},
"type":11072
}
{
"event": "20030",
"description": "新增群成员通知事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"memberList": [
{
"name": "{{data.member_list[0].name}}",
"user_id": "{{data.member_list[0].user_id}}"
}
],
"opUserId": "{{data.op_user_id}}",
"opUserName": "{{data.op_user_name}}",
"roomConversationId": "{{data.room_conversation_id}}",
"roomName": "{{data.room_name}}"
}
}

33
eventdata/11073.json Normal file
View File

@@ -0,0 +1,33 @@
{
"data":{
"member_list":[{
"name":"liju1n",
"user_id":"788130xx9115"
}],
"op_user_id":"16888505xx682",
"op_user_name":"小邪",
"room_conversation_id":"R:106xx787",
"room_name":"小邪、小邪"
},
"type":11073
}
{
"event": "20022",
"description": "群成员减少通知事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"memberList": [
{
"name": "{{data.member_list[0].name}}",
"user_id": "{{data.member_list[0].user_id}}"
}
],
"opUserId": "{{data.op_user_id}}",
"opUserName": "{{data.op_user_name}}",
"roomConversationId": "{{data.room_conversation_id}}",
"roomName": "{{data.room_name}}"
}
}

33
eventdata/11074.json Normal file
View File

@@ -0,0 +1,33 @@
{
"data":{
"member_list":[{
"name":"小邪",
"user_id":"78813xxx2093"
}],
"op_user_id":"16888505xx28682",
"op_user_name":"小邪",
"room_conversation_id":"R:1069604xxx056787",
"room_name":"小邪、小邪"
},
"type":11074
}
{
"event": "20020",
"description": "新增群通知事件",
"time": "",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "",
"memberList": [
{
"name": "{{data.member_list[0].name}}",
"user_id": "{{data.member_list[0].user_id}}"
}
],
"opUserId": "{{data.op_user_id}}",
"opUserName": "{{data.op_user_name}}",
"roomConversationId": "{{data.room_conversation_id}}",
"roomName": "{{data.room_name}}"
}
}

23
eventdata/11075.json Normal file
View File

@@ -0,0 +1,23 @@
{
"data":{
"op_user_id":"1688850xx682",
"op_user_name":"小邪",
"room_conversation_id":"R:1069xx013643",
"room_name":"一x、小邪"
},
"type":11075
}
{
"event": "20023",
"description": "主动退群通知事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"opUserId": "{{data.op_user_id}}",
"opUserName": "{{data.op_user_name}}",
"roomConversationId": "{{data.room_conversation_id}}",
"roomName": "{{data.room_name}}"
}
}

40
eventdata/11076.json Normal file
View File

@@ -0,0 +1,40 @@
{
"data":{
"acctid":"",
"avatar":"http:\/\/wx.qlogo.cn\/mmhead\/UOCHvzUGAIU6r51scmeNibicnYs4KzTtIee8QY0MwIRfkuke16H6M1Gw\/0",
"conversation_id":"S:16888xx328682_78813022xx2998",
"corp_id":"1970325xxx6788",
"mobile":"",
"nickname":"",
"position":"",
"realname":"",
"remark":"",
"sex":1,
"unionid":"ozynqsiVdxxx6y7gWmY3JY",
"user_id":"788130xx912998",
"username":"小邪"
},
"type":11076
}
{
"event": "20027",
"description": "好友新增通知事件",
"time": "",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "",
"avatarUrl": "{{data.avatar}}",
"corpId": "{{data.corp_id}}",
"acctId": "{{data.acctid}}",
"mobile": "{{data.mobile}}",
"position": "{{data.position}}",
"realName": "{{data.realname}}",
"remark": "{{data.remark}}",
"unionId": "{{data.unionid}}",
"nickname": "{{data.nickname}}",
"sex": "{{data.sex}}",
"userId": "{{data.user_id}}",
"username": "{{data.username}}"
}
}

25
eventdata/11077.json Normal file
View File

@@ -0,0 +1,25 @@
{
"data":{
"avatar":"http:\/\/wx.qlogo.cn\/mmhead\/UOCHvzUGAIU6r51scmeNibicnYs4KzTtIee8QY0MwIRfkukxxGw\/0",
"corp_id":"1970325xx6788",
"nickname":"小邪",
"sex":1,
"user_id":"788130xxxx912998",
},
"type":11077
}
{
"event": "20018",
"description": "好友删除通知事件",
"time": "",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "",
"avatarUrl": "{{data.avatar}}",
"corpId": "{{data.corp_id}}",
"nickname": "{{data.nickname}}",
"sex": "{{data.sex}}",
"userId": "{{data.user_id}}"
}
}

21
eventdata/11078.json Normal file
View File

@@ -0,0 +1,21 @@
{
"data":{
"op_user_id":"1688850xx2",
"room_conversation_id":"R:106xx3056787",
"room_name":"11212"
},
"type":11078
}
{
"event": "20024",
"description": "群名称变化通知事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"opUserId": "{{data.op_user_id}}",
"roomConversationId": "{{data.room_conversation_id}}",
"roomName": "{{data.room_name}}"
}
}

21
eventdata/11123.json Normal file
View File

@@ -0,0 +1,21 @@
{
"data":{
"message_server_id":"1099273", // 消息id
"op_user_id":"7881299524021868", // 撤回人的id
"room_id":"0" // 群id
},
"type":11123
}
{
"event": "20015",
"description": "撤回消息事件",
"time": "",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "",
"messageServerId": "{{data.message_server_id}}",
"opUserId": "{{data.op_user_id}}",
"roomId": "{{data.room_id}}"
}
}

52
eventdata/11124.json Normal file
View File

@@ -0,0 +1,52 @@
{
"data":{
"avatar":"http:\/\/wx.qlogo.cn\/finderhead\/98Nz5LFElxy52vwoicInGTY5DIt2gcvj7K19yibc0axW6ARtSF8icBWcA\/0",
"content_type":141,
"conversation_id":"S:1688850xx2_7881302283xx8",
"cover_url":"http:\/\/wxapp.tc.qq.com\/251\/20304\/stodownload?encfilekey=rjD5jyTuFrIpZ2ibE8T7YmwgiahniaXswqzv6bW118niaoUuZtsM9vJEDP61J01zLGiaHTbR0d00VcbO7uTscwibibHYb0fbuQ9oWlCNibRjqReXUqM&adaptivelytrans=0&bizid=1023&dotrans=0&hy=SH&idx=1&m=&scene=0&token=AxricY7RBHdXVEgMYiahMRju4usjnMDmfvUIhIkaOa8qL61MjdP1WbtKG7gzibx6TBNwryGwql5lnc&finder_expire_time=1640012841&finder_eid=export%2FUzFfAgtgekIEAQAAAAAAL3g1-GkDsAAAAAstQy6ubaLX4KHWvLEZgBPE1YJEBhhWbMj9zNPgMIAcUmbEgrgbsrYqsnwkUBiJ",
"desc":"Note 11 系列 120W 神仙秒充小剧场5 个关于超级速度的小故事\r\n?深度诠释:什么叫速战速决,快到想不到!",
"extras":"CAEQACKuFwAE9OmXBAAAAQAAAAAAbGk3vHhq0dqrbalht2EgAAAAaeq5SzX7s7sPwaz04zCEwYwyALHFYGIb\/l1etP1AtP1tGDa5lWo0wVLweieXaJyyyE4BdumgiONjSMgTcoVFGqNAeJSYwgYFeZQQ41KoeAK9e2JZXMza+LZuotsNmW3cxkxXcajc670SdduYZvaqPUh1e2J+AYC0eAvEmhbGDCMzd2b1PpJnlriDiJ2Np5Q96sbrAn7Im5HA3Qs22+zdoCHgD9P\/GGVmDkxdTudX+BRYlL3270nhOqBUlkca\/xiRnW+Pn9w+IWiVQPDAAOOLH\/gPmV4xHuAujXEHIhjZz0ZtKvV3kXRLPuuQQvALcI2Pr6dowAQuZdsoylsyTKZLGEK+pApavBzCd0dxeH7eMqYUfLcot5HfmNqGEw5OCCfzk0spIZfxI1G+GfpVcIthw2mag4I9\/nOMsL2Qw0YC\/nx0vz6VL3qgkKq7U9MSuizcO7kM5LsC5zZgsuBIhtN6IyxUoSkMkyoRAtL24\/RsdyhGP7soBd27kqUYfoHOruo\/69+3ebd2XX5JDGmeYx9TxtKUecX4D+aOSb3teLFbNSdhV3klobypko5wBrxHND0If5D8H\/XGo5xbRGKIXlStXJOmBgqQ74IpWU5MM05Ms1FyGxlxjmJ4qiLs51t0SOkG\/Qog2WBHQSQGXZdv4nj+iggORFhHHP\/gL8D6ZZ\/E+\/ksseQh4nHILIoBgit8XdzRtAmEaXT\/gGqMwwbNHERaW\/x4jGzi8iUF2KlmBPTE4snYGPadt9fGdTxK5jgIlXaUfISZsE7XcHo2S+tHDLcN4Y6YwjVRy9aRU5L7wED0KY9h6giHFSByPu1OrayykS\/5f3EIzghu823mB+l4vtRdbt\/kK+qENbniflsC6zTMrR7CzERkvKsfOTA8xt\/4y3jNCeMZweboL1GzOsFwHQpX4\/+WHp3EblQcy4XyR8tDu5HdqH\/M0a1kf52GlX9sj3WmUnUt9YXLR7MOiE9XdLfKzoVhVAa\/hzVgX\/NiUqy9JCZsowbSNf2W0v2gn6X0K73nDFaxFSscI42LYnk7rhcHVX6bOTRNM58zxtgpIcaEJG4DYUxDBJQNUoFkWKo5lCHlro4RPF9vHoW7EwwfFLiX55wmgqVBZTNhda\/pG+HoJ8JFijSSI6VEW+8zDO8mhnb4I0wnmjYdOoitXnHg71Rlo4uuI2BrNDi8ybDVsMr9BQXOXwdGhXdjfJA3TK1i3KPUVVZQVV59B\/vYReiAcpu7XP3Wspg17pUq5xE8IValC9FJl0rFT58+Q7r0kfQAvoegx\/NdbvyC2039ucOZiD4LMblPcGaW9zn8Jg9OVMNZOCttc+\/S8+B4De+SIO2TjHPLxxEey8GrHIKqegRAIdFl2yIGWs9lc03QOIHsv8wS5WUCTDXpW0ogQJZ7YA0scwq1PDqEed0ttGOshcMO\/9jpj4w925Juyj7Ub8zJZ7N1tAMZqpIfGz3hndrDYGuh2BmuL6uxpjN8dpPEZxYRdD9tG5Zfi8ZM4YZuYEs8+Q5Smz8qVJzbo8MJYSeRowkpjFVJkQPrYx9fOOAonb1lP3FNT2U4H6PMcc8HDOdpm7Jt0OajRQvIKEtWCHALxlP9VdtmI3uy1JpZNS\/waB0De8\/S7z337dg130jO4juKF\/9M+pkXzuZv0jjpLMK5KwLzh3SpCD1D7MAxQPcNfEj0stlti2rfvU2Gp7+\/SylMXQCU9sqhnSjeu4FD283\/2frUHjR3xEQO0X+88UmM3WBdzSqG02TZndkr1B36FGtih98gXdQdGs74QpZMUazNfH\/ULDDNUldvhlZjj5i8WKdNqHlbUD3SUrirXSQI\/QSGFF4HfP5Bz7HiVG9g47opjIFqKe3v1tr+6t6JhjE70poLQBsKAaP\/KXnbhZP3Smb0aqWGUW5BKgsy9hL0Df18XpuBz9U7eJll6WTPcsOsxRPy4dbZ6l34KxQEyj6wTdQp1J7\/iMrihneD3f3QoVI5bJgn9JMg6k3ZE\/zqOLak8g\/Jf3NP0+O7ddAp\/QOfFE4zZF9QTxB57hNTe3wdArSJzziMgsAWWOtLyQ6HlC2zzarVDjUKlpuBfwIstnXtC\/qsECJRc4P4\/jq1hmwUgvSKTniwINlxE4XDSBKr9x7wtrZAmUow7acIS2cQA3rNEtbFereMOWU49scjTjptrivArvoXv9s90+iREENU5E\/LG3Uk6geFQcsbbvY\/vYSmbqDuZ6czySGvayB16tNJtudR+6QOWl04iU1aDZ1mOvqZklrc4DgLFQ8WsO1v3duks++mww\/xdDhfAqfE04YFIiSMuRwOXm5x4xKcoRdru8PVGAHrbiCF13pVJF7MQ3c9784Oe5qtgVQbR\/cLsm2onZkdcsvWILEKTlmCERO4cBwoAId\/hb\/odqkL5\/fkGRXnnFSvu1gzGB3n6WveE9frgVBaybPCzBiYxU+K+FsJsrGEraO\/vp1WpgO+GP7ZHia1oAH0V0udtUI3l6mXY\/yDSBwTt1q3MrengDx9lxuk4YuNUQx9n77NXchc2WxaYEF3nP4SJ13JrKv\/x6awdVJAbjdlAcObHl4WUDo7tkeEUvu2qbijrsNQkCf60lRInznA9eFsn21WFYaCD4ioJN5ystO0KzxQYBX17dlh+6fJ\/vR1JH0wf81fYpmlOJ742IRUfi3wanE9nmqHfXN9bHL+w6\/H6EBNOBHQgsrbAQgYplOd2rkl+OlDRoCSO9ncSKLlX1vsujK7VeeaBalhXdNDOKU\/hRpTZ4iLJzOjPNtap\/mxmlUjGH1wPhZtt99KqVqJFxSKEQ9W8VdM+8JFjcm7LoNyVYv6bZ1MCAhnhZXOACKC09EcBpgDjmN\/l2TSO\/JmCY\/c5PD3apO2oymjEEvitwsfCK+soMYzkvKc6t4+lDrVHm41p3VOUHqgKNf+LDsFHsSxjokrcgt73bi7qzAjK3yvgni6PX\/vu2KCnQr0IdbW85Kk+y0gjYzunyuUdUPJSgIhma8lRmbcIODtvVh2X85iwAbJkPjQfBlJ1kOPbFeS8ie6NLraKkX+cPUcYI5Jed\/TuHeDQLTxNWJQ6seEysFFLMnYXdgiL9WgA9hNfyGlGV6mnx3Q\/aCNsKBbB5cqhe2TMrQlWPde2XirIyH11vARVLFXBYHerOqKvaDSEPycNS3VIqoVzyLzzcPJG4UQrCGL3fuCI73IGU1v+exOzPZT6ujuBMQP2mQPZ0RA1GbtIy0kcx1PLgCouO3D9xvxAKxjcvZfQRdy96HXItRe8SwN0+a4gZmbSoR9C55ekwnem9s7seO3LvEuFcFLF4KXSAn5qN4V69yD+jq3qzNThY5IocwiYS+xwVIRgg65ozDqaChGVvkjJwyxaEPSo0WTgWt0+Bl0IvEiFTOgW1YUKwM9bxII9XuVY88+rMddTmH7U1xvyAUIOpS53RQj3Ja3SGw6hwG37plewXpP7bm7B8GxvnKFQKnh9pSLW0Sn2JVmhDYsGH5PfXNUamPlurTnf35stlUgm06+g+9Bt9vKJkmqpbuiiQGWEzvhPllvxc9RzZvGpj8w9IFDNJjNlz6m9bK\/WFJBLclx7dQyGa7a2W7BRBnh5TDxiLN3Nq\/DtGngcEf3krxRIcCA2+z38Pl6Q3UpVe\/Lma4vkf6UsUA5FkQu07TKwlpeCi3SG\/g0HCbvk0p+yJlyCjh7ByOHsQF\/3zaESO8vwsYIhiXzNNt5HPbDXOTgybs0qcIj\/2vMOzFnX78AegJu9Y+J3SosvxmlpKnSh0lr4i5QsMfCTJ3FwkDp3WT4EC94ElrM1p4reqky1ccrcB\/aebke6yIZ2q4hfV\/wHDd1D8bc+fRHmnxl44bPQ+EGFkziB12CMEqKtRQRoI6hGibwraSPAWN9Z8c+yl0\/N\/Di45sJ\/71xfp\/L8EBugTZKbiNqM7nCBa9c2CH30+02zjc8qhwyRLkYkpKnVdSzC+qUKAA=",
"feed_type":4,
"is_pc":0,
"local_id":"2826",
"nickname":"Redmi红米手机",
"receiver":"1688850xxx8682",
"send_time":"1639408041",
"sender":"78813022xxx98",
"sender_name":"小邪",
"server_id":"1099038",
"thumb_url":"http:\/\/wxapp.tc.qq.com\/251\/20304\/stodownload?encfilekey=rjD5jyTuFrIpZ2ibE8T7YmwgiahniaXswqzv6bW118niaoUuZtsM9vJEDP61J01zLGiaHTbR0d00VcbO7uTscwibibHYb0fbuQ9oWlCNibRjqReXUqM&adaptivelytrans=0&bizid=1023&dotrans=0&hy=SH&idx=1&m=&scene=0&token=AxricY7RBHdXVEgMYiahMRju4usjnMDmfvUIhIkaOa8qL61MjdP1WbtKG7gzibx6TBNwryGwql5lnc&finder_expire_time=1640012841&finder_eid=export%2FUzFfAgtgekIEAQAAAAAAL3g1-GkDsAAAAAstQy6ubaLX4KHWvLEZgBPE1YJEBhhWbMj9zNPgMIAcUmbEgrgbsrYqsnwkUBiJ",
"url":"https:\/\/channels.weixin.qq.com\/web\/pages\/feed?eid=export%2FUzFfAgtgekIEAQAAAAAAL3g1-GkDsAAAAAstQy6ubaLX4KHWvLEZgBPE1YJEBhhWbMj9zNPgMIAcUmbEgrgbsrYqsnwkUBiJ"
},
"type":11124
}
{
"event": "20010",
"description": "视频号消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "{{data.local_id}}",
"serverId": "{{data.server_id}}",
"messageType": 103,
"coverUrl": "{{data.cover_url}}",
"desc": "{{data.desc}}",
"extras": "{{data.extras}}",
"feedType": 4,
"nickname": "{{data.nickname}}",
"objectId": "string",
"objectNonceId": "string",
"thumbUrl": "{{data.thumb_url}}",
"url": "{{data.url}}",
"appInfo": "",
"title": "",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc":0
}
}

25
eventdata/11173.json Normal file
View File

@@ -0,0 +1,25 @@
{
"data":{
"avatar":"http:\/\/wx.qlogo.cn\/mmhead\/UOCHvzUGAIU6r51scmeNibicnYs4KzTtIee8QY0MwIRfkukxxGw\/0",
"corp_id":"1970325xx6788",
"nickname":"小邪",
"sex":1,
"user_id":"788130xxxx912998",
},
"type":11173
}
{
"event": "20019",
"description": "被删除通知事件",
"time": "",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "",
"avatarUrl": "{{data.avatar}}",
"corpId": "{{data.corp_id}}",
"nickname": "{{data.nickname}}",
"sex": "{{data.sex}}",
"userId": "{{data.user_id}}"
}
}

51
eventdata/11174.json Normal file
View File

@@ -0,0 +1,51 @@
{
"data":{
"auth_deviceid":"",
"corpid":1970325xxx41886, // 公司id
"easykey":"",
"gid":0,
"gid_sk1":"",
"gid_tgt":"",
"icon_url":"http:\/\/wework.qpic.cn\/bizmail\/IHgRKfv9SqpPJEZUfiaUfqMkckbS0ELewgZkibwCwwxxxxwx":false,
"keep_sec":0,
"lang_type":0,
"logo":"https:\/\/p.qlogo.cn\/bizmail\/1icHEZ7oxxxic8d5PQsYBjI6YtiaMz3qP2JUfiafn3xxxyfQNa4sg\/0", // 公司头像
"nick_name":"小邪",
"qrcode_key":"2D1BF812D2E7F56BED5276EE7E1C5833",
"sk1":"",
"st_gid_ticket":"",
"status":1,
"tgt":"",
"vid":168885xx28682, // 用户id
"wxlogindata":{
}
},
"type":11174
}
{
"event": "20026",
"description": "登录二维码状态通知事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"authDeviceId": "{{data.auth_deviceid}}",
"corpId": "{{data.corpid}}",
"easyKey": "{{data.easykey}}",
"gid": "{{data.gid}}",
"gidSk1": "{{data.gid_sk1}}",
"gidTgt": "{{data.gid_tgt}}",
"iconUrl": "{{data.icon_url}}",
"keepSec": "{{data.keep_sec}}",
"langType": "{{data.lang_type}}",
"logo": "{{data.logo}}",
"nickname": "{{data.nick_name}}",
"qrcodeKey": "{{data.qrcode_key}}",
"sk1": "{{data.sk1}}",
"stGidTicket": "{{data.st_gid_ticket}}",
"status": "{{data.status}}",
"tgt": "{{data.tgt}}",
"vid": "{{data.vid}}"
}
}

54
eventdata/11195.json Normal file
View File

@@ -0,0 +1,54 @@
{
"data": {
"appinfo": "CAMQ/N78kwYYqtfywYKAgAMgxxxxA==",
"avatar": "http://wx.qlogo.cn/finderhead/ver_1/uKX9myKKepcU56XhO5XM6YxvSyD6TV3ZAJnqUMzV3kXAibmtcZ0882EOU8rUCibD5NibCyAy0TL5GIevM0owSrTx2FXZwIe4ORb0QecRAXmXos/0",
"content_type": 146,
"conversation_id": "S:78813xxxxx3_168885xxxxx82",
"cover_url": "http://wxapp.tc.qq.com/251/20350/stodownload?encfilekey=oibeqyX228riaCwo9STVsGLIBn9G5YG8ZnJujjw3bicy2C6VG5mQPSicDYVCVOhia6jbuX6P7RFZLzkVV2FiaOWgVXWd7MGM4tYFicBEmb781ZJicfOxUEN56wmOJvg7MdhONhYMkzC06To9PVM&adaptivelytrans=0&bizid=1023&dotrans=0&hy=SZ&idx=1&m=8d80177b490d9d27c78d3ef4d6e80ef3&token=x5Y29zUxcibB90eQl2iao3wM9KWnEdVy6khUsCxbwS6xG9OQnXMU4Snahdia1hjibCIB9x0CDPG7dibY&finder_expire_time=1657090220&finder_eid=export%2FUzFfAgtgekIEAQAAAAAAE-Ixm2BRsAAAAAstQy6ubaLX4KHWvLEZgBPExYM4dVgyZY6DzNPgMIAUe9mrgQQscNXyDyQgHbOX",
"desc": "下午好",
"extras": "",
"feed_type": 9,
"is_pc": 0,
"nickname": "星子Stella",
"object_id": "13895574026529675281",
"object_nonce_id": "13435211891441762521_0_0_0_0",
"receiver": "78813007xxxxxxxxx,
"send_time": "1656486902",
"sender": "16888xxxxxx",
"sender_name": "2123",
"server_id": "1111212",
"thumb_url": "http://wxapp.tc.qq.com/251/20350/stodownload?encfilekey=oibeqyX228riaCwo9STVsGLIBn9G5YG8ZnJujjw3bicy2C6VG5mQPSicDYVCVOhia6jbuX6P7RFZLzkVV2FiaOWgVXWd7MGM4tYFicBEmb781ZJicfOxUEN56wmOJvg7MdhONhYMkzC06To9PVM&adaptivelytrans=0&bizid=1023&dotrans=0&hy=SZ&idx=1&m=8d80177b490d9d27c78d3ef4d6e80ef3&token=x5Y29zUxcibB90eQl2iao3wM9KWnEdVy6khUsCxbwS6xG9OQnXMU4Snahdia1hjibCIB9x0CDPG7dibY&finder_expire_time=1657090220&finder_eid=export%2FUzFfAgtgekIEAQAAAAAAE-Ixm2BRsAAAAAstQy6ubaLX4KHWvLEZgBPExYM4dVgyZY6DzNPgMIAUe9mrgQQscNXyDyQgHbOX",
"url": "https://channels.weixin.qq.com/web/pages/live?eid=export%2FUzFfAgtgekIEAQAAAAAAE-Ixm2BRsAAAAAstQy6ubaLX4KHWvLEZgBPExYM4dVgyZY6DzNPgMIAUe9mrgQQscNXyDyQgHbOX"
},
"type": 11195
}
{
"event": "20016",
"description": "视频号直播消息事件",
"time": "{{data.send_time}}",
"data": {
"instanceId": "string",
"robotId": "string",
"sendTime": "{{data.send_time}}",
"localId": "string",
"serverId": "{{data.server_id}}",
"messageType": 103,
"textContent": "",
"avatarUrl": "{{data.avatar}}",
"coverUrl": "{{data.cover_url}}",
"desc": "{{data.desc}}",
"extras": "{{data.extras}}",
"feedType": "{{data.feed_type}}",
"nickname": "{{data.nickname}}",
"objectId": "{{data.object_id}}",
"objectNonceId": "{{data.object_nonce_id}}",
"thumbUrl": "{{data.thumb_url}}",
"url": "{{data.url}}",
"messageSource": 0,
"conversationId": "{{data.conversation_id}}",
"fromWxId": "{{data.sender}}",
"toWxId": "{{data.receiver}}",
"fromNickName": "{{data.sender_name}}",
"isPc": "{{data.is_pc}}"
}
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>清除本地存储</title>
</head>
<body>
<h1>正在清除本地存储...</h1>
<script>
localStorage.clear();
document.body.innerHTML = '<h1>本地存储已清除!请刷新主页面</h1>';
setTimeout(() => {
window.close();
}, 2000);
</script>
</body>
</html>

253
frontend/css/style.css Normal file
View File

@@ -0,0 +1,253 @@
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background-color: #f5f7fa;
color: #333;
display: flex;
height: 100vh;
overflow: hidden;
}
/* 侧边导航栏 */
nav {
width: 180px;
background-color: #2c3e50;
color: white;
display: flex;
flex-direction: column;
position: relative;
}
nav ul {
list-style: none;
padding: 20px 0;
}
nav ul li {
padding: 15px 20px;
cursor: pointer;
transition: all 0.3s ease;
}
nav ul li:hover {
background-color: #34495e;
transform: translateX(5px);
}
nav ul li a {
color: white;
text-decoration: none;
display: block;
font-size: 14px;
}
/* 主内容区 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
width: calc(100% - 180px);
}
/* 头部 */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 30px;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
z-index: 10;
}
header h1 {
font-size: 24px;
font-weight: bold;
}
header h1 .pro {
color: #3498db;
}
header .status {
color: #666;
}
header .top-btn {
padding: 8px 20px;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
header .top-btn:hover {
background-color: #c0392b;
}
/* 主内容区域 */
main {
flex: 1;
padding: 30px;
overflow-y: auto;
background-color: #f5f7fa;
}
/* 卡片容器 */
.cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
/* 卡片样式 */
.card {
background-color: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 150px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0,0,0,0.15);
}
.card-icon {
font-size: 48px;
color: #3498db;
margin-bottom: 10px;
}
.card-title {
color: #666;
font-size: 14px;
margin-bottom: 5px;
}
.card-value {
font-size: 28px;
font-weight: bold;
color: #333;
}
.card-btn {
margin-top: 10px;
padding: 8px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
.card-btn:hover {
background-color: #2980b9;
}
/* 特殊卡片样式 - 新增账号卡片有不同的按钮 */
.card:nth-child(4) .card-btn {
background-color: #2ecc71;
}
.card:nth-child(4) .card-btn:hover {
background-color: #27ae60;
}
/* 底部 */
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 30px;
background-color: white;
border-top: 1px solid #e0e0e0;
position: relative;
}
.status-indicator {
display: flex;
align-items: center;
}
.online {
color: #2ecc71;
font-size: 16px;
margin-right: 5px;
}
.version {
color: #666;
}
.company {
color: #999;
}
/* 适配Wails窗口 */
html, body {
width: 100%;
height: 100%;
}
/* 调整容器布局 */
header, main, footer {
width: 100%;
}
/* 滚动条样式 */
main::-webkit-scrollbar {
width: 8px;
}
main::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
main::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
main::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* 响应式设计 */
@media (max-width: 768px) {
.cards {
grid-template-columns: 1fr;
}
header {
padding: 15px 20px;
}
main {
padding: 20px;
}
footer {
padding: 15px 20px;
}
}

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>灵泽万川企微售后客服</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.js" type="module"></script>
</body>
</html>

76
frontend/js/app.js Normal file
View File

@@ -0,0 +1,76 @@
// 等待DOM加载完成
document.addEventListener('DOMContentLoaded', function() {
try {
// 导入Wails运行时API
import('../wailsjs/runtime/runtime.js').then(({ LogInfo, LogDebug, LogError }) => {
LogInfo('StarBot Pro frontend loaded successfully');
// 简化版本 - 只保留基本功能,移除可能导致问题的动画和复杂操作
// 侧边栏导航项点击事件 - 简化版
const navItems = document.querySelectorAll('nav ul li');
if (navItems.length > 0) {
navItems.forEach(item => {
item.addEventListener('click', function() {
LogDebug('导航项点击:', this.textContent.trim());
});
});
}
// 充值按钮点击事件 - 简化版
const rechargeBtn = document.querySelector('#rechargeBtn');
if (rechargeBtn) {
rechargeBtn.addEventListener('click', function() {
LogDebug('充值按钮点击');
});
}
// 添加账号按钮点击事件 - 简化版
const addAccountBtn = document.querySelector('#addAccountBtn');
if (addAccountBtn) {
addAccountBtn.addEventListener('click', function() {
LogDebug('添加账号按钮点击');
});
}
// 在页面上显示加载成功信息
const statusEl = document.createElement('div');
statusEl.style.position = 'fixed';
statusEl.style.bottom = '20px';
statusEl.style.right = '20px';
statusEl.style.padding = '10px';
statusEl.style.backgroundColor = '#2ecc71';
statusEl.style.color = 'white';
statusEl.style.borderRadius = '4px';
statusEl.textContent = '前端加载成功';
document.body.appendChild(statusEl);
}).catch(error => {
console.error('Failed to import Wails runtime:', error);
// 在页面上显示加载成功信息作为备选
const statusEl = document.createElement('div');
statusEl.style.position = 'fixed';
statusEl.style.bottom = '20px';
statusEl.style.right = '20px';
statusEl.style.padding = '10px';
statusEl.style.backgroundColor = '#2ecc71';
statusEl.style.color = 'white';
statusEl.style.borderRadius = '4px';
statusEl.textContent = '前端加载成功';
document.body.appendChild(statusEl);
});
} catch (error) {
console.error('Frontend error:', error);
// 在页面上显示错误信息
const errorEl = document.createElement('div');
errorEl.style.position = 'fixed';
errorEl.style.bottom = '20px';
errorEl.style.right = '20px';
errorEl.style.padding = '10px';
errorEl.style.backgroundColor = '#e74c3c';
errorEl.style.color = 'white';
errorEl.style.borderRadius = '4px';
errorEl.textContent = '前端加载错误: ' + error.message;
document.body.appendChild(errorEl);
}
});

1116
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.2.0",
"vite": "^3.0.7",
"vue": "^3.2.0"
},
"dependencies": {
"element-plus": "^2.11.1"
}
}

View File

@@ -0,0 +1 @@
db031e671111b343255373ca05cff100

661
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,661 @@
<template>
<div id="app">
<div v-if="isLoggedIn" class="layout-container">
<!-- 左侧菜单 -->
<nav class="side-nav">
<!-- 左侧顶栏 -->
<div class="side-nav-header">
<h1>灵泽万川企微售后客服</h1>
</div>
<!-- 菜单项 -->
<ul>
<li :class="{ 'active': activeSection === '系统设置' }"><a href="#" @click="navigate('系统设置')">系统首页</a></li>
<li :class="{ 'active': activeSection === '自动客服' }"><a href="#" @click="navigate('自动客服')">自动客服</a></li>
<li :class="{ 'active': activeSection === '售后问题库' }"><a href="#" @click="navigate('售后问题库')">售后问题库</a></li>
<li :class="{ 'active': activeSection === '工程师派单' }"><a href="#" @click="navigate('工程师派单')">工程师派单</a></li>
<li :class="{ 'active': activeSection === '知识库' }"><a href="#" @click="navigate('知识库')">知识库</a></li>
<li :class="{ 'active': activeSection === 'ERP监听' }"><a href="#" @click="navigate('ERP监听')">ERP监听</a></li>
<li :class="{ 'active': activeSection === '操作记录' }"><a href="#" @click="navigate('操作记录')">操作记录</a></li>
<li :class="{ 'active': activeSection === '回调配置' }"><a href="#" @click="navigate('回调配置')">回调配置</a></li>
</ul>
<!-- 底部运行天数和版本信息 -->
<div class="side-nav-footer">
<div class="status" style="text-align: left; font-size: 14px;">运行天数: <span
style="font-size: 16px; font-weight: bold;">{{ runningDays }}</span> </div>
<div class="version" style="text-align: left; font-size: 12px; color: #666;">版本号: <span>{{ appVersion
}}</span></div>
</div>
</nav>
<!-- 主内容区域 -->
<div class="main-content">
<main>
<!-- 动态内容区域 -->
<div v-if="activeSection === '系统设置'" class="system-settings-content">
<section class="workspace-hero">
<div>
<p class="eyebrow">灵泽万川企微售后客服</p>
<h2>售后客服工作台</h2>
<p class="hero-subtitle">统一查看企微连接自动客服运行和今日处理概况</p>
</div>
<div class="hero-actions">
<button id="startWxworkBtn" class="primary-action" @click="handleStartWxwork">启动企微</button>
<button class="secondary-action" @click="navigate('自动客服')">配置自动客服</button>
</div>
</section>
<section class="overview-grid">
<article class="overview-card">
<span class="metric-label">自动客服</span>
<strong :class="autoReplyStatus.enabled ? 'state-ok' : 'state-muted'">
{{ autoReplyStatus.enabled ? '已开启' : '未开启' }}
</strong>
<p>{{ autoReplyStatus.running ? '正在监听客户消息' : '未进入监听状态' }}</p>
</article>
<article class="overview-card">
<span class="metric-label">活跃账号</span>
<strong>{{ activeClientCount }}</strong>
<p>当前已识别并可用的企微账号</p>
</article>
<article class="overview-card">
<span class="metric-label">今日回复</span>
<strong>{{ autoReplyStatus.todayReplied || 0 }}</strong>
<p>AI 或本地规则已回复消息</p>
</article>
<article class="overview-card">
<span class="metric-label">今日转人工</span>
<strong>{{ autoReplyStatus.todayHandoff || 0 }}</strong>
<p>已通知人工接管的问题</p>
</article>
</section>
<section class="workspace-columns">
<div class="work-panel">
<div class="panel-heading">
<h3>运行概览</h3>
<span>{{ formatDuration(autoReplyStatus.lastTotalDurationMs) }}</span>
</div>
<div class="detail-list">
<div>
<span>知识库</span>
<strong>{{ autoReplyStatus.knowledgeFileCount || 0 }} 文件 / {{ autoReplyStatus.knowledgeChunkCount || 0 }} 片段</strong>
</div>
<div>
<span>向量索引</span>
<strong>{{ autoReplyStatus.embeddingChunkCount || 0 }} 片段</strong>
</div>
<div>
<span>最近 AI 耗时</span>
<strong>{{ formatDuration(autoReplyStatus.lastAiDurationMs) }}</strong>
</div>
<div>
<span>身份缓存</span>
<strong>内部 {{ autoReplyStatus.internalContactCount || 0 }} / 外部 {{ autoReplyStatus.externalContactCount || 0 }}</strong>
</div>
</div>
</div>
<div class="work-panel">
<div class="panel-heading">
<h3>快捷操作</h3>
<span>常用入口</span>
</div>
<div class="quick-actions">
<button @click="handleStartWxwork">启动企微</button>
<button @click="navigate('自动客服')">自动客服</button>
<button @click="navigate('售后问题库')">售后问题库</button>
<button @click="navigate('工程师派单')">工程师派单</button>
<button @click="navigate('知识库')">知识库</button>
<button @click="navigate('ERP监听')">ERP监听</button>
<button @click="navigate('操作记录')">操作记录</button>
</div>
</div>
</section>
<section class="home-account-panel">
<WxWorkAccount :account-info="wxWorkAccountInfo" />
</section>
</div>
<!-- 自动客服内容区域 -->
<div v-else-if="activeSection === '自动客服'" class="auto-reply-content">
<AutoReply />
</div>
<!-- 售后问题库内容区域 -->
<div v-else-if="activeSection === '售后问题库'" class="after-sales-content">
<AfterSalesIssues />
</div>
<!-- 工程师派单内容区域 -->
<div v-else-if="activeSection === '工程师派单'" class="engineer-dispatch-content">
<EngineerDispatch />
</div>
<!-- 知识库内容区域 -->
<div v-else-if="activeSection === '知识库'" class="after-sales-knowledge-content">
<AfterSalesKnowledge />
</div>
<!-- ERP监听内容区域 -->
<div v-else-if="activeSection === 'ERP监听'" class="kingdee-monitor-content">
<KingdeeMonitor />
</div>
<!-- 操作记录内容区域 -->
<div v-else-if="activeSection === '操作记录'" class="operation-logs-content">
<OperationLogs />
</div>
<!-- 回调配置内容区域 -->
<div v-else-if="activeSection === '回调配置'" class="callback-config-content">
<h2>回调配置</h2>
<div class="config-form">
<div class="form-group">
<label>Http回调地址</label>
<textarea v-model="callbackUrl" placeholder="请输入回调地址" rows="3"></textarea>
</div>
<div class="form-group">
<label>文件上传地址</label>
<textarea v-model="fileUploadUrl" placeholder="请输入文件上传地址" rows="3"></textarea>
</div>
<!-- <div class="form-group">
<label>回调Token配置</label>
<input v-model="callbackToken" type="text" placeholder="请输入回调Token">
</div> -->
<div class="form-group">
<label>http端口</label>
<input v-model="httpPort" type="text" placeholder="请输入http端口">
</div>
<div class="form-group">
<label>设备编码</label>
<input v-model="deviceCode" type="text" placeholder="请输入设备编码">
</div>
<div class="form-group">
<label>开启回调</label>
<label class="switch">
<input type="checkbox" v-model="enableCallback">
<span class="slider round"></span>
</label>
<span class="status-text">{{ enableCallback ? '已开启' : '已关闭' }}</span>
</div>
<div class="form-group">
<label>授权云管理端</label>
<label class="switch">
<input type="checkbox" v-model="enableCloudAuth">
<span class="slider round"></span>
</label>
<span class="status-text">{{ enableCloudAuth ? '已开启' : '已关闭' }}</span>
</div>
<div class="form-actions">
<button class="save-btn" @click="handleSaveConfig">保存设置</button>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- 状态通知 -->
<div v-if="showNotification" :class="['notification', notificationType]">
{{ notificationMessage }}
</div>
</div>
</template>
<style scoped>
.notification {
position: fixed;
right: 24px;
bottom: 24px;
color: var(--cmd-text);
padding: 10px 14px;
background-color: rgba(9, 22, 28, 0.96);
border: 1px solid rgba(72, 240, 220, 0.32);
border-radius: 6px;
box-shadow: var(--cmd-shadow), 0 0 22px rgba(72, 240, 220, 0.14);
z-index: 1000;
}
</style>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { SendWxWorkData, GetCallbackConfig, SaveCallbackConfig, GetActiveClientCount, GetAutoReplyStatus, LogFrontend } from '../wailsjs/go/main/App.js';
// 导入组件
import WxWorkAccount from '@/components/WxWorkAccount.vue';
import OperationLogs from '@/components/OperationLogs.vue';
import AutoReply from '@/components/AutoReply.vue';
import AfterSalesIssues from '@/components/AfterSalesIssues.vue';
import AfterSalesKnowledge from '@/components/AfterSalesKnowledge.vue';
import EngineerDispatch from '@/components/EngineerDispatch.vue';
import KingdeeMonitor from '@/components/KingdeeMonitor.vue';
// 状态变量
const showNotification = ref(false);
const notificationMessage = ref('');
const notificationType = ref('success');
const runningDays = ref(1);
const activeSection = ref('系统设置');
const appVersion = ref('2.1.3'); // 应用版本号
const wxWorkAccountInfo = ref(null);
const activeClientCount = ref(0); // 活跃账号数量
const autoReplyStatus = ref({});
const isLoggedIn = ref(true); // 无登录界面版本:启动后直接进入主界面
// 回调配置相关状态
const callbackUrl = ref('');
const callbackToken = ref('');
const httpPort = ref('');
const enableCallback = ref(false);
const enableCloudAuth = ref(false);
const fileUploadUrl = ref('');
const deviceCode = ref('');
// 定时器ID
let activeClientCountInterval = null;
let autoReplyStatusInterval = null;
let runningDaysInterval = null;
// 导航处理
async function navigate(section) {
LogFrontend('debug', `导航到: ${section}`);
activeSection.value = section;
}
// 保存回调配置
async function handleSaveConfig() {
LogFrontend('info', '保存回调配置');
try {
// 构建配置数据
const configData = {
callbackUrl: callbackUrl.value,
callbackToken: callbackToken.value,
httpPort: httpPort.value,
enableCallback: enableCallback.value,
enableCloudAuth: enableCloudAuth.value,
fileUploadUrl: fileUploadUrl.value,
deviceCode: deviceCode.value
};
// 调用后端API保存配置
LogFrontend('debug', `配置数据: ${JSON.stringify(configData)}`);
const jsonData = JSON.stringify(configData);
// 在Wails中Go函数的多返回值会被转换为JavaScript数组
// SaveCallbackConfig在后端返回的是(bool, string)
let success = false;
let message = '';
try {
const result = await SaveCallbackConfig(jsonData);
// 处理可能的返回值格式
if (Array.isArray(result)) {
[success, message] = result;
} else if (result === true) {
success = true;
} else if (typeof result === 'string') {
message = result;
}
} catch (err) {
message = err.message || '未知错误';
}
if (success) {
// 显示保存成功通知
showNotificationMessage('配置保存成功,请重启应用', 'success');
} else {
// 显示保存失败通知
//showNotificationMessage('保存配置失败: ' + message, 'error');
}
} catch (error) {
LogFrontend('error', `保存配置失败: ${error}`);
//showNotificationMessage('保存配置失败: ' + error.message, 'error');
}
}
// 从exe路径加载配置
async function loadConfigFromExePath() {
console.log('[信息] [前端] 尝试从exe同级目录config文件夹加载config.json');
try {
// 调用Go函数获取配置
const configFromExe = await GetCallbackConfig();
console.log('[调试] [前端] GetCallbackConfig原始返回:', configFromExe);
// 现在直接接收配置对象,不再处理元组
if (configFromExe && typeof configFromExe === 'object') {
console.log('[信息] [前端] 从exe路径成功加载配置:', configFromExe);
return configFromExe;
} else {
console.warn('[警告] [前端] 从exe路径加载配置失败返回格式不正确:', configFromExe);
return null;
}
} catch (error) {
console.error('[错误] [前端] 从exe路径加载配置时出错:', error);
return null;
}
}
// 加载回调配置优先从exe路径加载
async function loadCallbackConfig() {
try {
LogFrontend('info', '加载回调配置');
// 尝试从exe路径加载配置
const configFromExe = await loadConfigFromExePath();
if (configFromExe) {
// 使用从exe路径加载的配置
// 正确处理嵌套结构
const callbackConfig = configFromExe.callbackConfig || configFromExe.CallbackConfig || configFromExe;
callbackUrl.value = callbackConfig.callbackUrl || callbackConfig.CallbackURL || '';
callbackToken.value = callbackConfig.callbackToken || callbackConfig.CallbackToken || '';
httpPort.value = callbackConfig.httpPort || callbackConfig.HTTPPort || '10001';
enableCallback.value = callbackConfig.enableCallback !== undefined ? callbackConfig.enableCallback :
(callbackConfig.EnableCallback !== undefined ? callbackConfig.EnableCallback : false);
enableCloudAuth.value = callbackConfig.enableCloudAuth !== undefined ? callbackConfig.enableCloudAuth :
(callbackConfig.EnableCloudAuth !== undefined ? callbackConfig.EnableCloudAuth : false);
fileUploadUrl.value = callbackConfig.fileUploadUrl || callbackConfig.FileUploadUrl || '';
deviceCode.value = callbackConfig.deviceCode || callbackConfig.DeviceCode || '';
LogFrontend('info', `成功从exe路径加载回调配置: ${JSON.stringify({
callbackUrl: callbackUrl.value,
callbackToken: callbackToken.value,
httpPort: httpPort.value,
enableCallback: enableCallback.value,
enableCloudAuth: enableCloudAuth.value,
fileUploadUrl: fileUploadUrl.value
})}`);
} else {
// 使用默认值
callbackUrl.value = '';
callbackToken.value = '';
httpPort.value = '10001';
enableCallback.value = false;
enableCloudAuth.value = false;
fileUploadUrl.value = '';
deviceCode.value = '';
LogFrontend('info', '使用默认回调配置');
}
} catch (error) {
LogFrontend('error', `加载回调配置时出现异常: ${error}`);
// 异常时使用默认值
callbackUrl.value = '';
callbackToken.value = '';
httpPort.value = '10001';
enableCallback.value = false;
enableCloudAuth.value = false;
fileUploadUrl.value = '';
deviceCode.value = '';
LogFrontend('info', '异常时使用默认回调配置');
}
}
// 启动企微按钮处理
async function handleStartWxwork() {
LogFrontend('info', '启动企微按钮点击,正在发送启动请求...');
try {
// 启动企微进程
const requestData = {
"type": 10000,
"data": {}
};
const jsonData = JSON.stringify(requestData);
// 调用后端SendWxWorkData函数客户端ID暂时使用0
const result = await SendWxWorkData("0", jsonData);
// 处理可能的返回值格式
let success = false;
let error = null;
if (Array.isArray(result)) {
// 数组格式: [success, error]
if (result.length >= 2) {
[success, error] = result;
} else if (result.length === 1) {
success = result[0];
}
} else if (result && typeof result === 'object') {
// 对象格式: {success, error}
success = result.success;
error = result.error;
} else {
// 未知格式
LogFrontend('warn', `SendWxWorkData返回未知格式: ${JSON.stringify(result)}`);
success = Boolean(result);
}
if (error) {
LogFrontend('error', `发送启动企微请求失败: ${error}`);
//showNotificationMessage('启动企微失败: ' + error, 'error');
return;
}
if (success) {
LogFrontend('info', '启动企微请求发送成功');
//showNotificationMessage('已发送启动企微请求', 'success');
} else {
LogFrontend('error', '启动企微请求返回失败');
//showNotificationMessage('启动企微失败,请重试', 'error');
}
} catch (err) {
LogFrontend('error', `启动企微过程中出现异常: ${err}`);
//showNotificationMessage('启动企微时出现异常: ' + err.message, 'error');
}
}
// 计算运行天数从1开始
function calculateRunningDays() {
// 假设应用启动时间存储在localStorage中
let startTime = localStorage.getItem('appStartTime');
// 如果没有启动时间,设置为当前时间
if (!startTime) {
startTime = new Date().toISOString();
localStorage.setItem('appStartTime', startTime);
}
// 计算从启动时间到现在的天数差从1开始
const startDate = new Date(startTime);
const currentDate = new Date();
const timeDiff = currentDate - startDate;
const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24)) + 1; // 从1开始计算
return daysDiff;
}
// 显示通知
function showNotificationMessage(message, type = 'success', duration = 3000) {
notificationMessage.value = message;
notificationType.value = type;
showNotification.value = true;
setTimeout(() => {
showNotification.value = false;
}, duration);
}
// 登录处理方法
const handleLoginSuccess = (loginData) => {
isLoggedIn.value = true;
localStorage.setItem('isLoggedIn', 'true');
LogFrontend('info', `用户登录成功: ${loginData.username}`);
showNotificationMessage('登录成功,欢迎使用灵泽万川企微售后客服!', 'success');
// 登录成功后初始化应用数据
runningDays.value = calculateRunningDays();
loadCallbackConfig();
updateActiveClientCount();
loadAutoReplyStatus();
// 设置定时器
runningDaysInterval = setInterval(() => {
runningDays.value = calculateRunningDays();
}, 900000);
activeClientCountInterval = setInterval(updateActiveClientCount, 900000);
autoReplyStatusInterval = setInterval(loadAutoReplyStatus, 900000);
};
const handleLoginFailed = (error) => {
LogFrontend('error', `用户登录失败: ${error}`);
//showNotificationMessage('登录失败: ' + error, 'error');
};
// 获取活跃客户端数量
async function updateActiveClientCount() {
try {
// 检测是否在Wails环境中运行
const isWailsEnvironment = typeof window !== 'undefined' && window['go'] && window['go']['main'];
if (isWailsEnvironment) {
try {
// 在Wails环境中调用Go后端获取活跃客户端数量
const count = await GetActiveClientCount();
activeClientCount.value = count;
LogFrontend('debug', `获取活跃客户端数量: ${count}`);
} catch (error) {
LogFrontend('error', `获取活跃客户端数量失败: ${error}`);
//showNotificationMessage('获取活跃客户端数量失败:', error);
// 出错时使用0作为默认值
activeClientCount.value = 0;
}
} else {
// 在开发环境中,使用模拟数据
LogFrontend('debug', '在开发环境中,使用模拟活跃客户端数量');
activeClientCount.value = 3; // 开发环境模拟3个活跃客户端
}
} catch (error) {
LogFrontend('error', `更新活跃客户端数量时出错: ${error}`);
activeClientCount.value = 0;
}
}
async function loadAutoReplyStatus() {
try {
const isWailsEnvironment = typeof window !== 'undefined' && window['go'] && window['go']['main'];
if (!isWailsEnvironment) {
autoReplyStatus.value = {
enabled: true,
running: true,
todayReplied: 8,
todayHandoff: 1,
knowledgeFileCount: 12,
knowledgeChunkCount: 86,
embeddingChunkCount: 86,
lastTotalDurationMs: 1260,
lastAiDurationMs: 940,
internalContactCount: 6,
externalContactCount: 18
};
return;
}
const result = await GetAutoReplyStatus();
if (result?.success !== false && result?.data) {
autoReplyStatus.value = result.data;
}
} catch (error) {
LogFrontend('warn', `加载自动客服状态失败: ${error}`);
}
}
function formatDuration(value) {
const numeric = Number(value || 0);
if (!numeric) return '-';
if (numeric < 1000) return `${Math.round(numeric)} ms`;
return `${(numeric / 1000).toFixed(1)} s`;
}
// 生命周期钩子
onMounted(async () => {
// 每次启动时清空appStartTime
localStorage.removeItem('appStartTime');
// 无登录界面版本:始终直接进入主界面
isLoggedIn.value = true;
localStorage.setItem('isLoggedIn', 'true');
LogFrontend('info', 'StarBot Pro frontend loaded successfully');
LogFrontend('info', '当前为无登录界面版本,已自动进入主界面');
// 初始化运行天数
runningDays.value = calculateRunningDays();
// 加载回调配置
loadCallbackConfig();
// 获取初始活跃客户端数量
await updateActiveClientCount();
await loadAutoReplyStatus();
// 每90秒更新一次运行天数
runningDaysInterval = setInterval(() => {
runningDays.value = calculateRunningDays();
}, 90000);
// 每8秒更新一次活跃客户端数量
activeClientCountInterval = setInterval(updateActiveClientCount, 8000);
autoReplyStatusInterval = setInterval(loadAutoReplyStatus, 8000);
});
onUnmounted(() => {
// 清理工作
if (activeClientCountInterval) {
clearInterval(activeClientCountInterval);
}
if (autoReplyStatusInterval) {
clearInterval(autoReplyStatusInterval);
}
if (runningDaysInterval) {
clearInterval(runningDaysInterval);
}
});
</script>
<style>
@import './style.css';
@import './app.css';
/* 通知样式 */
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 14px;
border-radius: 6px;
color: var(--cmd-text);
border: 1px solid rgba(72, 240, 220, 0.28);
background-color: rgba(9, 22, 28, 0.96);
box-shadow: var(--cmd-shadow), 0 0 22px rgba(72, 240, 220, 0.14);
z-index: 1000;
}
.notification.success {
background-color: rgba(93, 242, 167, 0.12);
color: var(--cmd-green);
border-color: rgba(93, 242, 167, 0.34);
}
.notification.error {
background-color: rgba(255, 107, 125, 0.12);
color: var(--cmd-red);
border-color: rgba(255, 107, 125, 0.38);
}
</style>

765
frontend/src/app.css Normal file
View File

@@ -0,0 +1,765 @@
/* App shell */
.layout-container {
display: flex;
height: 100vh;
min-width: 960px;
color: var(--cmd-text);
background:
linear-gradient(rgba(72, 240, 220, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(72, 240, 220, 0.028) 1px, transparent 1px),
radial-gradient(circle at 18% 12%, rgba(72, 240, 220, 0.12), transparent 28%),
radial-gradient(circle at 90% 8%, rgba(99, 168, 255, 0.11), transparent 25%),
#071014;
background-size: 34px 34px, 34px 34px, auto, auto, auto;
}
.side-nav {
width: 252px;
flex-shrink: 0;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
color: var(--cmd-text-soft);
background:
linear-gradient(180deg, rgba(15, 35, 43, 0.98), rgba(4, 11, 14, 0.98)),
#071014;
border-right: 1px solid var(--cmd-line);
box-shadow: 18px 0 46px rgba(0, 0, 0, 0.34);
}
.side-nav::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(72, 240, 220, 0.09), transparent 42%),
repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0 1px, transparent 1px 7px);
}
.side-nav-header {
position: relative;
padding: 26px 22px 22px;
border-bottom: 1px solid rgba(72, 240, 220, 0.18);
}
.side-nav-header h1 {
margin: 0;
color: #f5feff;
font-size: 18px;
font-weight: 800;
line-height: 1.35;
letter-spacing: 0;
text-shadow: 0 0 18px rgba(72, 240, 220, 0.18);
}
.side-nav ul {
position: relative;
list-style: none;
padding: 16px 10px;
margin: 0;
flex: 1;
}
.side-nav ul li {
position: relative;
margin: 4px 0;
border-radius: var(--cmd-radius);
border: 1px solid transparent;
transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.side-nav ul li a {
display: block;
padding: 12px 14px;
color: var(--cmd-text-soft);
text-decoration: none;
font-size: 14px;
line-height: 1.4;
}
.side-nav ul li:hover {
border-color: rgba(72, 240, 220, 0.18);
background: rgba(72, 240, 220, 0.075);
}
.side-nav ul li.active {
border-color: rgba(72, 240, 220, 0.55);
background:
linear-gradient(90deg, rgba(72, 240, 220, 0.24), rgba(99, 168, 255, 0.12)),
rgba(13, 32, 39, 0.92);
box-shadow: inset 3px 0 0 var(--cmd-cyan), 0 0 24px rgba(72, 240, 220, 0.14);
}
.side-nav ul li.active a {
color: #f3ffff;
font-weight: 800;
}
.side-nav-footer {
position: relative;
padding: 16px 22px 20px;
color: var(--cmd-text-muted);
border-top: 1px solid rgba(72, 240, 220, 0.18);
background: rgba(0, 0, 0, 0.24);
}
.side-nav-footer .status,
.side-nav-footer .version {
font-size: 12px !important;
color: var(--cmd-text-muted) !important;
line-height: 1.8;
}
.side-nav-footer span {
color: var(--cmd-cyan);
text-shadow: 0 0 10px rgba(72, 240, 220, 0.28);
}
.main-content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 24px;
background:
radial-gradient(circle at 50% -10%, rgba(72, 240, 220, 0.08), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 180px);
}
.main-content main {
max-width: 1480px;
margin: 0 auto;
}
/* Workbench */
.system-settings-content {
display: flex;
flex-direction: column;
gap: 18px;
}
.workspace-hero {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px;
border: 1px solid var(--cmd-line-strong);
border-radius: var(--cmd-radius);
background:
linear-gradient(135deg, rgba(20, 45, 55, 0.94), rgba(8, 20, 25, 0.96)),
var(--cmd-panel);
box-shadow: var(--cmd-shadow), var(--cmd-glow);
}
.workspace-hero::after {
content: "";
position: absolute;
right: -14%;
top: -80%;
width: 46%;
height: 220%;
pointer-events: none;
transform: rotate(18deg);
background: linear-gradient(90deg, transparent, rgba(72, 240, 220, 0.12), transparent);
}
.eyebrow {
margin: 0 0 8px;
color: var(--cmd-cyan);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
}
.workspace-hero h2 {
margin: 0;
color: var(--cmd-text);
font-size: 28px;
line-height: 1.25;
}
.hero-subtitle {
margin: 10px 0 0;
color: var(--cmd-text-soft);
font-size: 14px;
}
.hero-actions,
.panel-actions,
.form-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.primary-action,
.secondary-action,
.save-btn,
.card-btn,
.primary-btn,
.ghost-btn,
.mini-primary,
.mini-ghost,
.danger-btn,
.small-btn,
.mini-btn,
.advanced-toggle {
min-height: 36px;
border-radius: 6px;
padding: 0 16px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.primary-action,
.save-btn,
.card-btn,
.primary-btn,
.mini-primary {
color: #031316;
border: 1px solid rgba(72, 240, 220, 0.82);
background: linear-gradient(135deg, var(--cmd-cyan), #63a8ff);
box-shadow: 0 0 20px rgba(72, 240, 220, 0.18);
}
.primary-action:hover,
.save-btn:hover,
.card-btn:hover,
.primary-btn:hover,
.mini-primary:hover {
transform: translateY(-1px);
box-shadow: 0 0 28px rgba(72, 240, 220, 0.28);
}
.secondary-action,
.ghost-btn,
.mini-ghost,
.mini-btn,
.advanced-toggle {
color: var(--cmd-cyan);
border: 1px solid rgba(72, 240, 220, 0.38);
background: rgba(9, 23, 29, 0.84);
}
.secondary-action:hover,
.ghost-btn:hover,
.mini-ghost:hover,
.mini-btn:hover,
.advanced-toggle:hover {
color: #eaffff;
border-color: rgba(72, 240, 220, 0.72);
background: rgba(72, 240, 220, 0.12);
box-shadow: 0 0 20px rgba(72, 240, 220, 0.14);
}
.danger-btn {
color: #ffd8dd;
border: 1px solid rgba(255, 107, 125, 0.48);
background: rgba(255, 107, 125, 0.1);
}
.danger-btn:hover {
border-color: rgba(255, 107, 125, 0.8);
background: rgba(255, 107, 125, 0.16);
}
button:disabled {
cursor: not-allowed !important;
opacity: 0.48;
transform: none !important;
box-shadow: none !important;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.overview-card,
.work-panel,
.config-form,
.wxwork-account-container,
.operation-logs-container,
.auto-reply-page {
border: 1px solid var(--cmd-line);
border-radius: var(--cmd-radius);
background: var(--cmd-panel);
box-shadow: var(--cmd-shadow);
}
.overview-card {
position: relative;
overflow: hidden;
padding: 18px;
min-height: 128px;
}
.overview-card::before,
.metric::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, var(--cmd-cyan), transparent);
opacity: 0.75;
}
.metric-label {
display: block;
color: var(--cmd-text-soft);
font-size: 13px;
margin-bottom: 10px;
}
.overview-card strong {
display: block;
color: var(--cmd-text);
font-size: 28px;
line-height: 1.1;
}
.overview-card p {
margin: 12px 0 0;
color: var(--cmd-text-muted);
font-size: 13px;
line-height: 1.5;
}
.state-ok,
.ok {
color: var(--cmd-green) !important;
}
.state-muted,
.muted {
color: var(--cmd-text-muted) !important;
}
.danger {
color: var(--cmd-red) !important;
}
.workspace-columns {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
gap: 14px;
}
.work-panel {
padding: 18px;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
}
.panel-heading h3 {
margin: 0;
color: var(--cmd-text);
font-size: 16px;
}
.panel-heading span {
color: var(--cmd-text-muted);
font-size: 13px;
}
.detail-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.detail-list div,
.quick-actions button {
border: 1px solid rgba(72, 240, 220, 0.16);
border-radius: 6px;
background: rgba(8, 21, 27, 0.78);
}
.detail-list div {
padding: 12px;
}
.detail-list span {
display: block;
color: var(--cmd-text-muted);
font-size: 12px;
margin-bottom: 6px;
}
.detail-list strong {
color: var(--cmd-text);
font-size: 14px;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.quick-actions button {
min-height: 42px;
color: var(--cmd-text-soft);
font-weight: 800;
cursor: pointer;
}
.quick-actions button:hover {
border-color: rgba(72, 240, 220, 0.58);
color: var(--cmd-cyan);
background: rgba(72, 240, 220, 0.1);
}
.home-account-panel {
margin-top: 18px;
border: 1px solid rgba(72, 240, 220, 0.22);
border-radius: 8px;
background: rgba(7, 22, 29, 0.72);
box-shadow: var(--cmd-shadow);
}
.home-account-panel .wxwork-account-container {
padding: 18px;
}
.home-account-panel .wxwork-account-container > h2 {
margin: 0 0 14px;
color: var(--cmd-text);
font-size: 20px;
}
/* Shared page surfaces */
.callback-config-content,
.auto-reply-content,
.after-sales-content,
.engineer-dispatch-content,
.after-sales-knowledge-content,
.operation-logs-content {
width: 100%;
}
.callback-config-content > h2 {
margin: 0 0 16px;
color: var(--cmd-text);
font-size: 24px;
}
.config-form {
width: 100%;
max-width: 760px;
padding: 20px;
}
.form-group {
display: grid;
grid-template-columns: 132px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.form-group label {
color: var(--cmd-text-soft);
font-size: 13px;
font-weight: 800;
}
.form-group input[type="text"],
.config-form .form-group textarea {
width: 100%;
max-width: none;
min-height: 36px;
border: 1px solid rgba(72, 240, 220, 0.22);
border-radius: 6px;
padding: 8px 10px;
color: var(--cmd-text);
background: rgba(7, 18, 23, 0.92);
font-size: 14px;
font-family: inherit;
}
.config-form .form-group textarea {
min-height: 92px;
resize: vertical;
}
.switch {
position: relative;
display: inline-block;
width: 42px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: rgba(127, 148, 156, 0.45);
transition: .2s;
border-radius: 999px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: #ffffff;
transition: .2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--cmd-cyan-strong);
}
input:checked + .slider:before {
transform: translateX(20px);
}
.status-text {
color: var(--cmd-cyan);
font-size: 13px;
font-weight: 800;
}
/* Element Plus dark command overrides */
.el-popper,
.el-select__popper,
.el-picker__popper {
color: var(--cmd-text) !important;
border: 1px solid rgba(72, 240, 220, 0.28) !important;
background: rgba(8, 20, 26, 0.98) !important;
box-shadow: var(--cmd-shadow) !important;
}
.el-select-dropdown,
.el-picker-panel,
.el-message-box,
.el-dialog {
color: var(--cmd-text) !important;
background: rgba(9, 22, 28, 0.98) !important;
border: 1px solid rgba(72, 240, 220, 0.24) !important;
box-shadow: var(--cmd-shadow), var(--cmd-glow) !important;
}
.el-select-dropdown__item {
color: var(--cmd-text-soft) !important;
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover,
.el-select-dropdown__item.selected {
color: var(--cmd-cyan) !important;
background: rgba(72, 240, 220, 0.12) !important;
}
.el-input__wrapper,
.el-textarea__inner,
.el-select .el-input__wrapper,
.el-select__wrapper {
color: var(--cmd-text) !important;
background: rgba(6, 17, 22, 0.9) !important;
border: 1px solid rgba(72, 240, 220, 0.22) !important;
box-shadow: none !important;
}
.el-input__wrapper:hover,
.el-input__wrapper.is-focus,
.el-select__wrapper:hover,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
border-color: rgba(72, 240, 220, 0.62) !important;
box-shadow: 0 0 18px rgba(72, 240, 220, 0.12) !important;
}
.el-input__inner,
.el-textarea__inner,
.el-select__placeholder,
.el-select__selected-item,
.el-select__selected-item span {
color: var(--cmd-text) !important;
caret-color: var(--cmd-cyan);
}
.el-input__inner::placeholder,
.el-textarea__inner::placeholder,
.el-select__placeholder.is-transparent {
color: var(--cmd-text-muted) !important;
}
.el-select__caret,
.el-input__suffix,
.el-input__prefix {
color: var(--cmd-text-soft) !important;
}
.el-table {
--el-table-border-color: rgba(72, 240, 220, 0.16) !important;
--el-table-header-bg-color: rgba(13, 31, 39, 0.98) !important;
--el-table-tr-bg-color: rgba(8, 20, 26, 0.94) !important;
--el-table-row-hover-bg-color: rgba(72, 240, 220, 0.09) !important;
--el-table-current-row-bg-color: rgba(72, 240, 220, 0.12) !important;
color: var(--cmd-text-soft) !important;
background: transparent !important;
}
.el-table th.el-table__cell,
.el-table tr,
.el-table td.el-table__cell,
.el-table__body-wrapper {
background: transparent !important;
}
.el-table th.el-table__cell {
color: #d7feff !important;
background: rgba(13, 31, 39, 0.98) !important;
font-weight: 800;
}
.el-table td.el-table__cell {
color: var(--cmd-text-soft) !important;
border-bottom-color: rgba(72, 240, 220, 0.13) !important;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: rgba(255, 255, 255, 0.025) !important;
}
.el-table__body tr:hover > td.el-table__cell {
background: rgba(72, 240, 220, 0.09) !important;
}
.el-table .el-table-fixed-column--right,
.el-table .el-table-fixed-column--left,
.el-table th.el-table-fixed-column--right,
.el-table th.el-table-fixed-column--left,
.el-table td.el-table-fixed-column--right,
.el-table td.el-table-fixed-column--left {
background: rgb(8, 24, 30) !important;
}
.el-table th.el-table-fixed-column--right,
.el-table th.el-table-fixed-column--left {
background: rgb(13, 34, 42) !important;
}
.el-table td.el-table-fixed-column--right::before,
.el-table th.el-table-fixed-column--right::before {
content: "";
position: absolute;
inset: 0 auto 0 -18px;
width: 18px;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.42));
}
.el-table td.el-table-fixed-column--left::after,
.el-table th.el-table-fixed-column--left::after {
content: "";
position: absolute;
inset: 0 -18px 0 auto;
width: 18px;
pointer-events: none;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.42), transparent);
}
.el-table__empty-text {
color: var(--cmd-text-muted) !important;
}
.el-tabs__item {
color: var(--cmd-text-soft) !important;
font-weight: 800;
}
.el-tabs__item.is-active,
.el-tabs__item:hover {
color: var(--cmd-cyan) !important;
}
.el-tabs__active-bar {
background-color: var(--cmd-cyan) !important;
box-shadow: 0 0 14px rgba(72, 240, 220, 0.42);
}
.el-tabs__nav-wrap::after {
background-color: rgba(72, 240, 220, 0.16) !important;
}
.el-dialog__title,
.el-message-box__title,
.el-dialog__body,
.el-message-box__content {
color: var(--cmd-text) !important;
}
.el-overlay {
background-color: rgba(1, 6, 8, 0.72) !important;
}
.el-switch.is-checked .el-switch__core {
border-color: var(--cmd-cyan-strong) !important;
background-color: var(--cmd-cyan-strong) !important;
}
.el-loading-mask {
background-color: rgba(5, 13, 17, 0.76) !important;
}
@media (max-width: 1200px) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-columns {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.layout-container {
min-width: 0;
}
.side-nav {
width: 220px;
}
.main-content {
padding: 16px;
}
.workspace-hero {
align-items: flex-start;
flex-direction: column;
}
.overview-grid,
.detail-list,
.quick-actions {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,574 @@
<template>
<div class="knowledge-page">
<section class="knowledge-toolbar">
<div>
<h2>售后知识库</h2>
<p>沉淀已处理售后案例并同步到自动客服知识检索</p>
</div>
<div class="toolbar-actions">
<button class="ghost-btn" @click="loadAll" :disabled="busy">刷新</button>
</div>
</section>
<section class="status-grid">
<div class="metric">
<span>已处理案例</span>
<strong>{{ cases.length }}</strong>
</div>
<div class="metric">
<span> Excel 归档</span>
<strong>{{ pendingCount }}</strong>
</div>
<div class="metric">
<span>Excel 表格</span>
<strong>{{ archives.length }}</strong>
</div>
<div class="metric wide">
<span>知识目录</span>
<strong class="path-text">{{ knowledgeDir }}</strong>
</div>
</section>
<el-tabs v-model="activeTab" class="knowledge-tabs">
<el-tab-pane label="已处理案例" name="cases">
<section class="filter-row">
<el-input v-model="caseFilters.keyword" clearable placeholder="搜索客户、群聊、问题、处理方案" />
<el-select v-model="caseFilters.engineer" class="filter-select" filterable>
<el-option label="全部工程师" value="all" />
<el-option label="未分配" value="unassigned" />
<el-option v-for="item in engineerOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="caseFilters.room" class="filter-select" filterable>
<el-option label="全部群聊" value="all" />
<el-option v-for="item in roomOptions" :key="item" :label="item" :value="item" />
</el-select>
</section>
<section class="table-panel">
<el-table :data="filteredCases" border stripe height="calc(100vh - 405px)" empty-text="暂无已处理知识案例">
<el-table-column prop="resolvedAt" label="处理时间" width="150" fixed>
<template #default="{ row }">{{ formatDateTime(row.resolvedAt) }}</template>
</el-table-column>
<el-table-column prop="roomName" label="群聊" width="170" />
<el-table-column prop="customerName" label="客户" width="120" />
<el-table-column prop="issueContent" label="问题" min-width="240">
<template #default="{ row }"><div class="multiline">{{ row.issueContent || '-' }}</div></template>
</el-table-column>
<el-table-column prop="resolutionContent" label="最终处理方案" min-width="280">
<template #default="{ row }"><div class="multiline strong-text">{{ row.resolutionContent || '-' }}</div></template>
</el-table-column>
<el-table-column label="负责人" width="150">
<template #default="{ row }">{{ engineerName(row) }}</template>
</el-table-column>
<el-table-column label="知识文件" min-width="260">
<template #default="{ row }">
<span class="path-value">{{ row.markdownPath || '-' }}</span>
<em v-if="row.missingMarkdown" class="missing">文件缺失</em>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<button class="ghost-btn small-btn" @click="editCase(row)">编辑</button>
<button class="ghost-btn small-btn" @click="openCase(row)">打开</button>
</template>
</el-table-column>
</el-table>
</section>
</el-tab-pane>
<el-tab-pane label="Excel归档" name="archives">
<section class="archive-actions">
<button class="primary-btn" @click="archivePending" :disabled="busy || pendingCount === 0">保存本次新增</button>
<button class="ghost-btn" @click="openArchiveFolder" :disabled="busy">打开目录</button>
</section>
<section class="table-panel">
<el-table :data="archives" border stripe height="calc(100vh - 405px)" empty-text="暂无知识库表格">
<el-table-column prop="displayTime" label="保存时间" width="170" fixed>
<template #default="{ row }">{{ row.displayTime || formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="fileName" label="Excel 文件" min-width="260">
<template #default="{ row }">
<div class="file-cell">
<span>{{ row.fileName }}</span>
<em v-if="row.missingFile">文件缺失</em>
</div>
</template>
</el-table-column>
<el-table-column prop="issueCount" label="问题数" width="110" />
<el-table-column prop="path" label="路径" min-width="360">
<template #default="{ row }"><span class="path-value">{{ row.path }}</span></template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<button class="ghost-btn small-btn" @click="openArchive(row.path)">打开</button>
</template>
</el-table-column>
</el-table>
</section>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="editVisible" title="编辑最终处理方案" width="680px" destroy-on-close>
<div class="edit-dialog">
<p>{{ editingCase.issueContent || '-' }}</p>
<el-input v-model="editingResolution" type="textarea" :rows="8" placeholder="填写工程师确认后的最终处理方案" />
</div>
<template #footer>
<button class="ghost-btn" @click="editVisible = false" :disabled="busy">取消</button>
<button class="primary-btn" @click="saveCase" :disabled="busy || !editingResolution.trim()">保存</button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
ArchivePendingAfterSalesIssues,
GetPendingAfterSalesArchiveSummary,
ListAfterSalesKnowledgeArchives,
ListAfterSalesKnowledgeCases,
RevealAfterSalesKnowledgeArchive,
RevealAfterSalesKnowledgeCase,
UpdateAfterSalesKnowledgeCase
} from '../../wailsjs/go/main/App.js'
const activeTab = ref('cases')
const archives = ref([])
const cases = ref([])
const summary = ref({})
const busy = ref(false)
const editVisible = ref(false)
const editingCase = ref({})
const editingResolution = ref('')
const caseFilters = reactive({
keyword: '',
engineer: 'all',
room: 'all'
})
const pendingCount = computed(() => Number(summary.value?.pendingCount || 0))
const knowledgeDir = computed(() => {
const firstPath = cases.value.find(item => item.markdownPath)?.markdownPath || ''
const archivePath = archives.value.find(item => item.path)?.path || ''
const path = firstPath || archivePath
const index = Math.max(path.lastIndexOf('\\'), path.lastIndexOf('/'))
return index > 0 ? path.slice(0, index) : 'config\\knowledge\\after_sales_cases'
})
const engineerOptions = computed(() => uniqueSorted(cases.value.map(engineerName).filter(item => item && item !== '-')))
const roomOptions = computed(() => uniqueSorted(cases.value.map(item => item.roomName).filter(Boolean)))
const filteredCases = computed(() => {
const q = caseFilters.keyword.trim().toLowerCase()
return cases.value
.filter(item => {
if (caseFilters.engineer === 'all') return true
if (caseFilters.engineer === 'unassigned') return engineerName(item) === '-'
return engineerName(item) === caseFilters.engineer
})
.filter(item => caseFilters.room === 'all' || item.roomName === caseFilters.room)
.filter(item => {
if (!q) return true
return [item.roomName, item.customerName, item.issueContent, item.aiSuggestion, item.resolutionContent, engineerName(item)]
.some(text => String(text || '').toLowerCase().includes(q))
})
})
function unwrapResult(result) {
if (!result || typeof result !== 'object') {
return { success: Boolean(result), message: '', data: result }
}
return {
success: result.success !== false,
message: String(result.message || result.error || ''),
data: result.data
}
}
async function loadCases() {
const result = unwrapResult(await ListAfterSalesKnowledgeCases())
if (!result.success) {
ElMessage.error(result.message || '加载已处理案例失败')
cases.value = []
return
}
cases.value = Array.isArray(result.data) ? result.data : []
}
async function loadArchives() {
const result = unwrapResult(await ListAfterSalesKnowledgeArchives())
if (!result.success) {
ElMessage.error(result.message || '加载 Excel 归档失败')
archives.value = []
return
}
archives.value = Array.isArray(result.data) ? result.data : []
}
async function loadSummary() {
const result = unwrapResult(await GetPendingAfterSalesArchiveSummary())
summary.value = result.success ? (result.data || {}) : {}
}
async function loadAll() {
busy.value = true
try {
await Promise.all([loadCases(), loadArchives(), loadSummary()])
} finally {
busy.value = false
}
}
async function archivePending() {
busy.value = true
try {
const result = unwrapResult(await ArchivePendingAfterSalesIssues())
if (!result.success) {
ElMessage.error(result.message || '保存到 Excel 归档失败')
return
}
ElMessage.success(result.message || '已保存到 Excel 归档')
await Promise.all([loadArchives(), loadSummary()])
} catch (err) {
ElMessage.error(`保存到 Excel 归档失败: ${err.message || err}`)
} finally {
busy.value = false
}
}
function editCase(row) {
editingCase.value = row
editingResolution.value = row.resolutionContent || ''
editVisible.value = true
}
async function saveCase() {
busy.value = true
try {
const result = unwrapResult(await UpdateAfterSalesKnowledgeCase(editingCase.value.issueId, editingResolution.value.trim()))
if (!result.success) {
ElMessage.error(result.message || '保存案例失败')
return
}
ElMessage.success('知识案例已更新,知识索引正在后台更新')
editVisible.value = false
await loadCases()
} catch (err) {
ElMessage.error(`保存案例失败: ${err.message || err}`)
} finally {
busy.value = false
}
}
async function openCase(row) {
try {
const result = await RevealAfterSalesKnowledgeCase(row.issueId || '')
const ok = Array.isArray(result) ? result[0] : Boolean(result?.success ?? result)
const msg = Array.isArray(result) ? result[1] : String(result?.message || '')
if (!ok) ElMessage.error(msg || '打开失败')
} catch (err) {
ElMessage.error(`打开失败: ${err.message || err}`)
}
}
async function openArchive(path) {
try {
const result = await RevealAfterSalesKnowledgeArchive(path || '')
const ok = Array.isArray(result) ? result[0] : Boolean(result)
const msg = Array.isArray(result) ? result[1] : ''
if (!ok) ElMessage.error(msg || '打开失败')
} catch (err) {
ElMessage.error(`打开失败: ${err.message || err}`)
}
}
function openArchiveFolder() {
openArchive('')
}
function engineerName(row) {
return row.assignedEngineerName || row.assignedEngineerId || '-'
}
function uniqueSorted(items) {
return [...new Set(items.map(item => String(item || '').trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
}
function formatDateTime(value) {
const text = String(value || '')
if (!text) return '-'
const date = new Date(text)
if (Number.isNaN(date.getTime())) return text
const pad = number => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
onMounted(loadAll)
</script>
<style scoped>
.knowledge-page {
color: #18282d;
padding: 18px;
background: #f5f7f8;
border: 1px solid #dfe5e8;
border-radius: 8px;
}
.knowledge-toolbar,
.toolbar-actions,
.filter-row,
.archive-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.knowledge-toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.knowledge-toolbar h2 {
margin: 0 0 6px;
font-size: 24px;
}
.knowledge-toolbar p {
margin: 0;
color: #667085;
font-size: 13px;
}
.status-grid {
display: grid;
grid-template-columns: 150px 150px 150px minmax(260px, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.metric {
min-width: 0;
padding: 14px;
border: 1px solid #dfe5e8;
border-radius: 8px;
background: #fff;
}
.metric span {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 12px;
}
.metric strong {
display: block;
font-size: 22px;
}
.path-text,
.path-value {
display: block;
overflow: hidden;
color: #475467;
font-family: Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-tabs {
padding: 12px;
border: 1px solid #dfe5e8;
border-radius: 8px;
background: #fff;
}
.filter-row,
.archive-actions {
margin-bottom: 12px;
}
.filter-row .el-input {
width: 320px;
}
.filter-select {
width: 180px;
}
.primary-btn,
.ghost-btn {
min-height: 34px;
border-radius: 6px;
padding: 0 12px;
font-weight: 700;
cursor: pointer;
}
.primary-btn {
border: 1px solid #16706d;
background: #16706d;
color: #fff;
}
.ghost-btn {
border: 1px solid #16706d;
background: #fff;
color: #16706d;
}
.small-btn {
min-height: 30px;
padding: 0 9px;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.table-panel {
min-height: 320px;
}
.multiline {
white-space: pre-wrap;
line-height: 1.5;
color: #24363b;
}
.strong-text {
font-weight: 600;
}
.missing,
.file-cell em {
display: block;
margin-top: 4px;
color: #b42318;
font-size: 12px;
font-style: normal;
}
.edit-dialog p {
margin: 0 0 12px;
color: #475467;
line-height: 1.5;
white-space: pre-wrap;
}
@media (max-width: 1080px) {
.status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-row .el-input,
.filter-select {
width: 100%;
}
}
/* Command center skin */
.knowledge-page {
color: var(--cmd-text);
border-color: var(--cmd-line);
background: transparent;
}
.knowledge-toolbar,
.metric,
.knowledge-tabs {
border-color: var(--cmd-line);
background: var(--cmd-panel);
box-shadow: var(--cmd-shadow);
}
.knowledge-toolbar {
position: relative;
overflow: hidden;
padding: 18px;
border: 1px solid var(--cmd-line);
border-radius: var(--cmd-radius);
background:
linear-gradient(135deg, rgba(22, 49, 60, 0.9), rgba(8, 20, 26, 0.96)),
var(--cmd-panel);
}
.knowledge-toolbar::before,
.metric::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, var(--cmd-cyan), transparent);
}
.knowledge-toolbar h2 {
color: var(--cmd-text);
}
.knowledge-toolbar p,
.metric span,
.edit-dialog p {
color: var(--cmd-text-soft);
}
.metric {
position: relative;
overflow: hidden;
}
.metric strong {
color: var(--cmd-text);
text-shadow: 0 0 18px rgba(72, 240, 220, 0.12);
}
.path-text,
.path-value {
color: #9fd9df;
}
.knowledge-tabs {
background: rgba(8, 20, 26, 0.92);
}
.multiline {
color: var(--cmd-text-soft);
}
.strong-text {
color: var(--cmd-text);
}
.missing,
.file-cell em {
color: var(--cmd-red);
}
.edit-dialog :deep(.el-textarea__inner) {
color: var(--cmd-text);
background: rgba(5, 15, 20, 0.94);
border-color: rgba(72, 240, 220, 0.24);
}
.primary-btn {
color: #031316;
border-color: rgba(72, 240, 220, 0.82);
background: linear-gradient(135deg, var(--cmd-cyan), var(--cmd-blue));
box-shadow: 0 0 20px rgba(72, 240, 220, 0.16);
}
.ghost-btn {
color: var(--cmd-cyan);
border-color: rgba(72, 240, 220, 0.38);
background: rgba(9, 23, 29, 0.84);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,623 @@
<template>
<div class="kingdee-page">
<section class="kingdee-header">
<div>
<h2>ERP监听</h2>
<p>监听金蝶销售订单排产状态首次完成排产时自动通知对应会话</p>
</div>
<div class="header-actions">
<button class="primary-btn" @click="saveConfig" :disabled="busy">保存配置</button>
<button class="ghost-btn" @click="testConnection" :disabled="busy">测试连接</button>
<button class="ghost-btn" @click="runOnce" :disabled="busy">手动扫描</button>
<button class="ghost-btn" @click="loadAll" :disabled="busy">刷新</button>
</div>
</section>
<section class="status-grid">
<div class="metric">
<span>监听状态</span>
<strong :class="status.running ? 'ok' : 'muted'">{{ status.running ? '运行中' : '未运行' }}</strong>
</div>
<div class="metric">
<span>累计扫描</span>
<strong>{{ status.totalPolled || 0 }}</strong>
</div>
<div class="metric">
<span>已通知</span>
<strong class="ok">{{ status.totalNotified || 0 }}</strong>
</div>
<div class="metric">
<span>未映射</span>
<strong class="warn">{{ status.totalUnmapped || 0 }}</strong>
</div>
<div class="metric">
<span>上次扫描</span>
<strong>{{ formatTime(status.lastPollAt) }}</strong>
</div>
</section>
<div v-if="message" class="inline-message" :class="{ error: messageType === 'error' }">{{ message }}</div>
<section class="kingdee-layout">
<div class="config-column">
<section class="kingdee-panel">
<div class="panel-heading">
<h3>金蝶连接</h3>
<span>{{ config.enabled ? '已开启' : '已关闭' }}</span>
</div>
<div class="form-grid">
<label class="switch-row">
<span>开启监听</span>
<input type="checkbox" v-model="config.enabled">
</label>
<label>
<span>服务地址</span>
<input v-model.trim="config.baseUrl" placeholder="https://example.com/k3cloud">
</label>
<label>
<span>账套ID</span>
<input v-model.trim="config.acctId">
</label>
<label>
<span>用户名</span>
<input v-model.trim="config.username">
</label>
<label>
<span>密码</span>
<input v-model="config.password" type="password" autocomplete="new-password">
</label>
<label>
<span>语言/区域</span>
<input v-model.number="config.lcid" type="number" min="1">
</label>
</div>
</section>
<section class="kingdee-panel">
<div class="panel-heading">
<h3>监听规则</h3>
<span>{{ config.pollIntervalSeconds || 60 }} </span>
</div>
<div class="form-grid">
<label>
<span>轮询间隔</span>
<input v-model.number="config.pollIntervalSeconds" type="number" min="10">
</label>
<label>
<span>销售订单表单编码</span>
<input v-model.trim="config.formId">
</label>
<label>
<span>订单号字段</span>
<input v-model.trim="config.billNoFieldKey">
</label>
<label>
<span>订单ID字段</span>
<input v-model.trim="config.orderIdFieldKey">
</label>
<label>
<span>客户编码字段</span>
<input v-model.trim="config.customerFieldKey">
</label>
<label>
<span>排产状态字段</span>
<input v-model.trim="config.statusFieldKey">
</label>
<label>
<span>完成状态值</span>
<input v-model.trim="config.completedValue">
</label>
<label>
<span>修改时间字段</span>
<input v-model.trim="config.modifyTimeFieldKey">
</label>
</div>
<label class="full-field">
<span>通知模板</span>
<textarea v-model="config.notifyTemplate" rows="3"></textarea>
</label>
</section>
</div>
<div class="config-column">
<section class="kingdee-panel">
<div class="panel-heading">
<h3>通知映射</h3>
<button class="small-btn" type="button" @click="addMapping">新增映射</button>
</div>
<div class="mapping-list">
<div class="mapping-header">
<span>ERP客户编码</span>
<span>机器人账号</span>
<span>通知会话ID</span>
<span>备注</span>
<span></span>
</div>
<div v-for="(row, index) in mappingRows" :key="row.localId" class="mapping-row">
<input v-model.trim="row.customerNumber">
<input v-model.trim="row.robotId">
<input v-model.trim="row.conversationId">
<input v-model.trim="row.remark">
<button class="icon-btn" type="button" @click="removeMapping(index)"></button>
</div>
<p v-if="mappingRows.length === 0" class="empty-text">暂无通知映射</p>
</div>
</section>
<section class="kingdee-panel">
<div class="panel-heading">
<h3>运行记录</h3>
<span>{{ status.status || 'stopped' }}</span>
</div>
<div class="record-block">
<h4>最近通知</h4>
<div v-if="recentNotices.length === 0" class="empty-text">暂无通知记录</div>
<div v-for="item in recentNotices" :key="`${item.orderKey}-${item.notifiedAt}`" class="record-item">
<strong>{{ item.billNo || item.orderKey }}</strong>
<span>{{ item.customerNumber }} / {{ formatTime(item.notifiedAt) }}</span>
<p>{{ item.message }}</p>
</div>
</div>
<div class="record-block">
<h4>最近错误</h4>
<div v-if="recentErrors.length === 0" class="empty-text">暂无错误</div>
<div v-for="item in recentErrors" :key="`${item.message}-${item.createdAt}`" class="record-item error">
<strong>{{ item.billNo || item.customerNumber || '系统' }}</strong>
<span>{{ formatTime(item.createdAt) }}</span>
<p>{{ item.message }}</p>
</div>
</div>
</section>
</div>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
GetKingdeeMonitorConfig,
GetKingdeeMonitorStatus,
RunKingdeeMonitorOnce,
SaveKingdeeMonitorConfig,
TestKingdeeMonitorConnection
} from '../../wailsjs/go/main/App.js'
const defaultTemplate = '您好,您的订单 {{billNo}} 已完成排产,后续我们会继续跟进生产进度。'
const busy = ref(false)
const message = ref('')
const messageType = ref('success')
const status = ref({})
const mappingRows = ref([])
const config = ref(defaultConfig())
const recentNotices = computed(() => status.value.recentNotices || [])
const recentErrors = computed(() => status.value.recentErrors || [])
function defaultConfig() {
return {
enabled: false,
baseUrl: '',
acctId: '',
username: '',
password: '',
lcid: 2052,
pollIntervalSeconds: 60,
formId: 'SAL_SaleOrder',
billNoFieldKey: 'FBillNo',
orderIdFieldKey: 'FID',
customerFieldKey: 'FCustId.FNumber',
statusFieldKey: '',
completedValue: '排产已完成',
modifyTimeFieldKey: 'FModifyDate',
notifyTemplate: defaultTemplate,
customerMappings: {}
}
}
function setMessage(text, type = 'success') {
message.value = text || ''
messageType.value = type
}
async function loadAll() {
busy.value = true
try {
await Promise.all([loadConfig(), loadStatus()])
} finally {
busy.value = false
}
}
async function loadConfig() {
const result = await GetKingdeeMonitorConfig()
if (result?.success === false) {
setMessage(result.message || '加载金蝶配置失败', 'error')
return
}
const data = result?.data || result || {}
config.value = { ...defaultConfig(), ...data }
mappingRows.value = mappingsToRows(config.value.customerMappings || {})
}
async function loadStatus() {
const result = await GetKingdeeMonitorStatus()
if (result?.success === false) {
setMessage(result.message || '加载监听状态失败', 'error')
return
}
status.value = result?.data || {}
}
async function saveConfig() {
busy.value = true
try {
const payload = normalizedConfig()
const result = await SaveKingdeeMonitorConfig(JSON.stringify(payload))
const [ok, msg] = normalizeTupleResult(result)
if (!ok) {
setMessage(msg || '保存失败', 'error')
return
}
config.value = payload
setMessage('金蝶监听配置已保存')
await loadStatus()
} catch (err) {
setMessage(`保存失败: ${err.message || err}`, 'error')
} finally {
busy.value = false
}
}
async function testConnection() {
busy.value = true
try {
const result = await TestKingdeeMonitorConnection(JSON.stringify(normalizedConfig()))
if (result?.success === false) {
setMessage(result.message || '连接失败', 'error')
return
}
setMessage(result?.message || '金蝶连接正常')
} catch (err) {
setMessage(`连接失败: ${err.message || err}`, 'error')
} finally {
busy.value = false
}
}
async function runOnce() {
busy.value = true
try {
const result = await RunKingdeeMonitorOnce()
if (result?.success === false) {
setMessage(result.message || '扫描失败', 'error')
} else {
const data = result?.data || {}
setMessage(`扫描完成:查询 ${data.polled || 0} 条,通知 ${data.notified || 0}`)
}
await loadStatus()
} catch (err) {
setMessage(`扫描失败: ${err.message || err}`, 'error')
} finally {
busy.value = false
}
}
function normalizedConfig() {
return {
...config.value,
pollIntervalSeconds: Number(config.value.pollIntervalSeconds || 60),
lcid: Number(config.value.lcid || 2052),
customerMappings: rowsToMappings(mappingRows.value)
}
}
function mappingsToRows(mappings) {
return Object.entries(mappings || {}).map(([customerNumber, item], index) => ({
localId: `${customerNumber}-${index}-${Date.now()}`,
customerNumber,
robotId: item?.robotId || '',
conversationId: item?.conversationId || '',
remark: item?.remark || ''
}))
}
function rowsToMappings(rows) {
const mappings = {}
for (const row of rows) {
const customerNumber = String(row.customerNumber || '').trim()
if (!customerNumber) continue
mappings[customerNumber] = {
robotId: String(row.robotId || '').trim(),
conversationId: String(row.conversationId || '').trim(),
remark: String(row.remark || '').trim()
}
}
return mappings
}
function addMapping() {
mappingRows.value.push({
localId: `new-${Date.now()}-${mappingRows.value.length}`,
customerNumber: '',
robotId: '',
conversationId: '',
remark: ''
})
}
function removeMapping(index) {
mappingRows.value.splice(index, 1)
}
function normalizeTupleResult(result) {
if (Array.isArray(result)) return [Boolean(result[0]), result[1] || '']
if (result === true) return [true, 'success']
if (result && typeof result === 'object') return [result.success !== false, result.message || '']
return [Boolean(result), '']
}
function formatTime(value) {
const numeric = Number(value || 0)
if (!numeric) return '-'
return new Date(numeric * 1000).toLocaleString()
}
onMounted(loadAll)
</script>
<style scoped>
.kingdee-page {
color: #d6eef2;
min-width: 920px;
}
.kingdee-header,
.kingdee-panel,
.metric {
background: rgba(7, 24, 30, 0.74);
border: 1px solid rgba(72, 240, 220, 0.22);
border-radius: 8px;
}
.kingdee-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
margin-bottom: 14px;
}
.kingdee-header h2,
.panel-heading h3,
.record-block h4 {
margin: 0;
}
.kingdee-header p {
margin: 6px 0 0;
color: #91b5bd;
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.primary-btn,
.ghost-btn,
.small-btn,
.icon-btn {
border: 1px solid rgba(72, 240, 220, 0.45);
border-radius: 6px;
color: #e9ffff;
background: rgba(20, 70, 78, 0.78);
cursor: pointer;
height: 34px;
padding: 0 12px;
}
.primary-btn {
background: linear-gradient(135deg, #34e7d5, #58a6ff);
color: #062027;
font-weight: 700;
}
.ghost-btn,
.small-btn,
.icon-btn {
background: rgba(10, 28, 34, 0.86);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.status-grid {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.metric {
padding: 12px 14px;
}
.metric span {
display: block;
color: #8fb1b8;
font-size: 13px;
margin-bottom: 6px;
}
.metric strong {
font-size: 22px;
}
.ok {
color: #3df2d0;
}
.warn {
color: #ffd166;
}
.muted {
color: #9fb3b9;
}
.inline-message {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid rgba(72, 240, 220, 0.28);
background: rgba(18, 66, 64, 0.7);
}
.inline-message.error {
border-color: rgba(255, 113, 113, 0.42);
background: rgba(78, 26, 34, 0.78);
}
.kingdee-layout {
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(420px, 1fr);
gap: 14px;
}
.config-column {
display: flex;
flex-direction: column;
gap: 14px;
}
.kingdee-panel {
padding: 16px;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.panel-heading span {
color: #72f0de;
font-size: 13px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
label,
.full-field {
display: flex;
flex-direction: column;
gap: 6px;
color: #a9c8ce;
font-size: 13px;
}
.switch-row {
justify-content: center;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(72, 240, 220, 0.22);
border-radius: 6px;
background: rgba(2, 15, 20, 0.86);
color: #eaffff;
min-height: 34px;
padding: 8px 10px;
outline: none;
}
textarea {
resize: vertical;
line-height: 1.5;
}
.full-field {
margin-top: 12px;
}
.mapping-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mapping-header,
.mapping-row {
display: grid;
grid-template-columns: 0.8fr 1fr 1.25fr 0.8fr 42px;
gap: 8px;
align-items: center;
}
.mapping-header {
color: #8fb1b8;
font-size: 12px;
padding: 0 2px;
}
.icon-btn {
width: 38px;
padding: 0;
}
.record-block + .record-block {
margin-top: 16px;
}
.record-item {
border-top: 1px solid rgba(72, 240, 220, 0.12);
padding: 10px 0;
}
.record-item strong,
.record-item span {
display: block;
}
.record-item span,
.empty-text {
color: #8fb1b8;
font-size: 13px;
}
.record-item p {
margin: 6px 0 0;
color: #d6eef2;
line-height: 1.5;
}
.record-item.error p {
color: #ffb4b4;
}
@media (max-width: 1180px) {
.kingdee-layout,
.status-grid {
grid-template-columns: 1fr;
}
.kingdee-page {
min-width: 0;
}
}
</style>

View File

@@ -0,0 +1,533 @@
<template>
<div v-if="isVisible" class="login-modal-overlay">
<div class="login-modal">
<div class="login-header">
<h2>账号登录</h2>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username">账号</label>
<input id="username" v-model="username" type="text" placeholder="请输入账号" required />
</div>
<div class="form-group">
<label for="password">密码</label>
<input id="password" v-model="password" type="password" placeholder="请输入密码" required />
</div>
<div class="form-group">
<label for="captcha">验证码</label>
<div class="captcha-container">
<input id="captcha" v-model="captcha" type="text" placeholder="请输入验证码" maxlength="4" required />
<img :src="captchaImage" alt="验证码" @click="refreshCaptcha" class="captcha-image" />
</div>
</div>
<!--<div class="checkbox-group">
<label class="checkbox-label">
<input v-model="rememberPassword" type="checkbox" />
记住密码
</label>
</div>-->
<div class="checkbox-group">
<label class="checkbox-label">
<input v-model="agreeTerms" type="checkbox" required />
(勾选即代表同意)软件仅用于测试学习场景不得用于非法途径
</label>
</div>
<div class="form-actions">
<button type="submit" class="login-btn" :disabled="!agreeTerms">
登录
</button>
<a href="#" class="forgot-password" @click.prevent="handleForgotPassword">
忘记密码
</a>
</div>
</form>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { SaveCallbackConfig, GetCallbackConfig } from '../../wailsjs/go/main/App.js';
const props = defineProps({
isVisible: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['login-success', 'login-failed']);
const username = ref('');
const password = ref('');
const captcha = ref('');
const rememberPassword = ref(false);
const agreeTerms = ref(false);
const errorMessage = ref('');
const captchaImage = ref('');
// 生成简单的验证码
const generateCaptcha = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 4; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
const captchaText = ref('');
const refreshCaptcha = () => {
captchaText.value = generateCaptcha();
// 创建简单的SVG验证码
const svgCaptcha = `
<svg width="100" height="40" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#f0f0f0"/>
<text x="10" y="25" font-family="Arial" font-size="20" fill="#333"
letter-spacing="8" transform="rotate(-5 10 25)">
${captchaText.value}
</text>
<path d="M0,20 Q50,10 100,30" stroke="#ddd" stroke-width="1" fill="none"/>
<path d="M0,30 Q50,40 100,20" stroke="#ddd" stroke-width="1" fill="none"/>
</svg>
`;
captchaImage.value = 'data:image/svg+xml;base64,' + btoa(svgCaptcha);
};
const handleLogin = async () => {
// 验证码验证
if (captcha.value.toUpperCase() !== captchaText.value.toUpperCase()) {
errorMessage.value = '验证码错误,请重新输入';
refreshCaptcha();
return;
}
// 基础验证
if (!username.value || !password.value) {
errorMessage.value = '请输入账号和密码';
return;
}
try {
errorMessage.value = '';
//const response = await fetch('https://crm.cxier.com/admin-api/system/auth/work-pw-login', {
const response = await fetch('https://crm.yelangjiang.com/admin-api/system/auth/work-pw-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username.value,
password: password.value
})
});
const result = await response.json();
if (result.code === 0 && result.data) {
// 登录成功
const { userId, accessToken, refreshToken, expiresTime } = result.data;
// 保存登录信息到本地存储
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('userId', userId.toString());
localStorage.setItem('tokenExpires', expiresTime.toString());
// 将accessToken保存到config.json的callbackToken字段
try {
// 先获取当前配置
const currentConfig = await window.go.main.App.GetCallbackConfig();
// 构建正确的CallbackConfig结构
const callbackConfigData = {
// 获取现有的callbackConfig或创建一个空对象
...(currentConfig && typeof currentConfig === 'object' && currentConfig.callbackConfig ? currentConfig.callbackConfig : {}),
// 更新callbackToken字段
callbackToken: accessToken,
// 确保所有必需字段都存在
callbackUrl: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.callbackUrl ? currentConfig.callbackConfig.callbackUrl : '',
httpPort: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.httpPort ? currentConfig.callbackConfig.httpPort : '10001',
enableCallback: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.enableCallback !== undefined ? currentConfig.callbackConfig.enableCallback : false,
enableCloudAuth: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.enableCloudAuth !== undefined ? currentConfig.callbackConfig.enableCloudAuth : false,
fileUploadUrl: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.fileUploadUrl ? currentConfig.callbackConfig.fileUploadUrl : '',
deviceCode: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.deviceCode ? currentConfig.callbackConfig.deviceCode : ''
};
const jsonData = JSON.stringify(callbackConfigData);
await SaveCallbackConfig(jsonData);
} catch (err) {
console.error('保存callbackToken失败:', err);
}
// 记住密码功能
if (rememberPassword.value) {
localStorage.setItem('rememberedUsername', username.value);
localStorage.setItem('rememberedPassword', password.value);
} else {
localStorage.removeItem('rememberedUsername');
localStorage.removeItem('rememberedPassword');
}
// 触发登录成功事件
emit('login-success', {
username: username.value,
userId,
accessToken,
rememberPassword: rememberPassword.value
});
} else {
// 登录失败
errorMessage.value = result.msg || '登录失败,请检查账号密码';
refreshCaptcha();
}
} catch (error) {
console.error('登录请求失败:', error);
errorMessage.value = '网络连接失败,请检查网络后重试';
refreshCaptcha();
}
};
const handleForgotPassword = () => {
errorMessage.value = '请联系软件提供者重置密码';
};
// 加载记住的密码
const loadRememberedCredentials = () => {
const rememberedUsername = localStorage.getItem('rememberedUsername');
const rememberedPassword = localStorage.getItem('rememberedPassword');
if (rememberedUsername && rememberedPassword) {
username.value = rememberedUsername;
password.value = rememberedPassword;
rememberPassword.value = true;
}
};
onMounted(() => {
refreshCaptcha();
loadRememberedCredentials();
});
</script>
<style scoped>
.login-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.login-modal {
background: white;
border-radius: 20px;
padding: 50px 60px;
width: 420px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
animation: slideUp 0.4s ease-out;
position: relative;
overflow: hidden;
}
.login-modal::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.login-header h2 {
margin: 0 0 40px 0;
text-align: center;
color: #2c3e50;
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-form {
width: 100%;
text-align: left;
margin: 0;
padding: 0;
}
.form-group {
margin-bottom: 20px;
text-align: left;
margin-left: 0;
padding-left: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-size: 14px;
font-weight: 500;
text-align: left;
width: 40px;
}
.form-group input[type="text"] {
width: 100%;
padding: 15px 18px;
border: 2px solid #e8ecf0;
border-radius: 12px;
font-size: 16px;
box-sizing: border-box;
text-align: left;
display: block;
margin-left: -3px;
transition: all 0.3s ease;
background: #f8fafb;
}
.form-group input[type="password"] {
width: 100%;
padding: 15px 18px;
border: 2px solid #e8ecf0;
border-radius: 12px;
font-size: 16px;
box-sizing: border-box;
text-align: left;
display: block;
margin: 0;
transition: all 0.3s ease;
background: #f8fafb;
}
.form-group input[type="text"]:focus,
.form-group input[type="password"]:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
.form-group input[type="text"]:hover,
.form-group input[type="password"]:hover {
border-color: #667eea;
background: white;
}
.captcha-group {
text-align: left;
}
.captcha-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.captcha-container input {
flex: 1;
min-width: 0;
}
.captcha-image {
height: 48px;
cursor: pointer;
border: 2px solid #e8ecf0;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafb, #e8ecf0);
flex-shrink: 0;
transition: all 0.3s ease;
}
.captcha-image:hover {
border-color: #667eea;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.checkbox-group {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.checkbox-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-size: 14px;
font-weight: 500;
text-align: left;
width: auto;
}
.checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
font-size: 15px;
color: #5a6c7d;
text-align: left;
line-height: 1.5;
transition: color 0.3s ease;
width: 100%;
box-sizing: border-box;
}
.checkbox-label:hover {
color: #667eea;
}
.checkbox-label input[type="checkbox"] {
margin-right: 8px;
width: auto;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 30px;
}
.login-btn {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
padding: 16px 40px;
border-radius: 12px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
width: 150px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
}
.login-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.login-btn:hover:not(:disabled)::before {
left: 100%;
}
.login-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
.login-btn:hover:not(:disabled) {
background: #40a9ff;
}
.login-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.forgot-password {
color: #1890ff;
text-decoration: none;
font-size: 14px;
}
.forgot-password:hover {
text-decoration: underline;
}
.error-message {
color: #ff4d4f;
text-align: center;
margin-top: 10px;
font-size: 14px;
}
.checkmark {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid #e8ecf0;
border-radius: 4px;
margin-right: 10px;
position: relative;
transition: all 0.3s ease;
background: white;
}
.checkbox-label:hover .checkmark {
border-color: #667eea;
}
.checkbox-label input[type="checkbox"]:checked+.checkmark {
background: linear-gradient(135deg, #667eea, #764ba2);
border-color: #667eea;
}
.checkbox-label input[type="checkbox"]:checked+.checkmark::after {
content: '✓';
position: absolute;
top: 0;
left: 3px;
color: white;
font-size: 12px;
font-weight: bold;
width: 100%;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="operation-logs-container">
<div class="logs-header">
<h2>操作记录</h2>
</div>
<div v-if="errorMessage" class="logs-error">
{{ errorMessage }}
</div>
<div class="logs-table-container">
<el-table v-loading="isLoading" :data="sortedLogs" class="logs-table" border>
<el-table-column prop="time" label="时间" width="90" />
<el-table-column prop="source" label="来源" width="110" />
<el-table-column prop="type" label="类型" width="80">
<template #default="scope">
<span
:class="{
'text-info': scope.row.type === 'info',
'text-warning': scope.row.type === 'warning',
'text-danger': scope.row.type === 'error'
}"
>
{{ scope.row.type }}
</span>
</template>
</el-table-column>
<el-table-column prop="content" label="内容" min-width="260" />
<el-table-column prop="duration" label="耗时(ms)" width="110" align="right" />
</el-table>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import { DebugLoadLogEntries, LogFrontend } from '../../wailsjs/go/main/App.js';
const logs = ref([]);
const isLoading = ref(false);
const errorMessage = ref('');
const sortedLogs = computed(() => {
return [...logs.value].sort((a, b) => Number(b.id || 0) - Number(a.id || 0)).slice(0, 10);
});
async function loadOperationLogs() {
isLoading.value = true;
try {
LogFrontend('info', 'loading operation logs...');
const result = await DebugLoadLogEntries();
LogFrontend('info', 'operation logs loaded', result);
if (Array.isArray(result?.entries)) {
logs.value = result.entries;
errorMessage.value = result.success === false
? (result.error || '操作日志文件损坏/读取失败,当前显示内存中的临时记录。')
: '';
return;
}
logs.value = [];
errorMessage.value = result?.error || '操作日志返回格式异常。';
} catch (error) {
logs.value = [];
errorMessage.value = error?.message || '操作日志加载失败。';
LogFrontend('error', 'operation log load threw: ' + errorMessage.value);
} finally {
isLoading.value = false;
}
}
onMounted(() => {
loadOperationLogs();
});
</script>
<style scoped>
.operation-logs-container {
width: 100%;
padding: 20px;
box-sizing: border-box;
color: #18282d;
}
.logs-header {
margin-bottom: 16px;
text-align: left;
}
.logs-header h2 {
margin: 0;
color: #18282d;
font-size: 24px;
font-weight: 600;
}
.logs-error {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #f3c7c7;
border-radius: 6px;
background: #fff2f2;
color: #9b1c1c;
font-size: 13px;
}
.logs-table-container {
overflow-x: auto;
border: 1px solid #dfe5e8;
border-radius: 8px;
background-color: #fff;
box-shadow: none;
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table :deep(.el-table__header-wrapper) th {
background-color: #f8fafb;
font-weight: 600;
color: #415056;
font-size: 14px;
}
.logs-table :deep(.el-table__body-wrapper) td {
font-size: 13px;
padding: 8px 1px;
}
.logs-table :deep(.el-table__body-wrapper) tr:hover {
background-color: #f8fafb;
}
.text-info {
color: #16706d;
font-weight: 500;
}
.text-warning {
color: #e6a23c;
font-weight: 500;
}
.text-danger {
color: #f56c6c;
font-weight: 500;
}
/* Command center skin */
.operation-logs-container {
color: var(--cmd-text);
border-color: var(--cmd-line);
background: var(--cmd-panel);
box-shadow: var(--cmd-shadow);
}
.logs-header h2 {
color: var(--cmd-text);
}
.logs-error {
color: var(--cmd-red);
border-color: rgba(255, 107, 125, 0.34);
background: rgba(255, 107, 125, 0.1);
}
.logs-table-container {
border-color: var(--cmd-line);
background: rgba(8, 20, 26, 0.9);
}
.logs-table :deep(.el-table__header-wrapper) th {
color: var(--cmd-text);
background-color: rgba(13, 31, 39, 0.98);
}
.logs-table :deep(.el-table__body-wrapper) td {
color: var(--cmd-text-soft);
}
.logs-table :deep(.el-table__body-wrapper) tr:hover {
background-color: rgba(72, 240, 220, 0.09);
}
.text-info {
color: var(--cmd-cyan);
}
.text-warning {
color: var(--cmd-amber);
}
.text-danger {
color: var(--cmd-red);
}
</style>

View File

@@ -0,0 +1,548 @@
<template>
<div class="wxwork-account-container">
<div class="account-toolbar-actions">
<button class="btn login-btn" @click="handleStartNewInstance" :disabled="startingInstance">
{{ startingInstance ? '启动中...' : '新增企微实例' }}
</button>
<button class="btn refresh-btn" @click="fetchAccountInfo" :disabled="loading">刷新</button>
</div>
<h2>企微账号信息</h2>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<div v-else-if="accounts.length > 0" class="accounts-list">
<div v-for="account in accounts" :key="account.user_id" class="account-card">
<!-- 头像区域 -->
<div class="avatar-container">
<img :src="account.avatar || '/default-avatar.png'" alt="用户头像" class="avatar">
<span class="status-indicator" :class="account.status === 1 ? 'online' : 'offline'"> </span>
</div>
<!-- 信息区域 -->
<div class="info-container">
<p class="user-id">clientId: {{ account.client_id || account.clientId || '-' }} / PID: {{ account.pid || '-' }}</p>
<p class="user-id">巡检: {{ account.health_message || account.healthMessage || account.runtime_status || '-' }}</p>
<p class="user-id">最后消息: {{ account.last_message_at || account.lastMessageAt || '-' }}</p>
<div class="name-status">
<h3>{{ account.username || account.name || '未知用户' }}</h3>
<span class="status-badge" :class="account.status === 1 ? 'online' : 'offline'">
{{ account.status === 1 ? '在线' : '离线' }}
</span>
</div>
<p class="user-id">用户ID: {{ account.user_id || account.userId || '未知ID' }}</p>
<!-- <p class="username">用户名: {{ account.username || account.name || '未知用户名' }}</p> -->
</div>
<!-- 操作按钮区域 -->
<div class="action-buttons">
<button v-if="account.status === 1" class="btn logout-btn" @click="handleLogout(account.user_id)">
下线
</button>
<button v-else class="btn delete-btn" @click="handleDelete(account.user_id)">
删除
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-user-slash"></i>
<h3>暂无企微账号</h3>
<p v-if="diagnostic" class="diagnostic">{{ diagnostic }}</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { LogFrontend, SendWxWorkData, StartNewWxWorkInstance } from '../../wailsjs/go/main/App.js'
// 移除旧的日志导入
// import { LogInfo, LogError } from '@/../wailsjs/runtime/runtime.js';
// 接收父组件传递的账号信息
const props = defineProps({
accountInfo: {
type: Object,
default: null
}
});
// 浏览器环境下的完整Wails运行时模拟 - 提供所有可能用到的API
if (typeof window.runtime === 'undefined') {
window.runtime = {
// 日志相关方法
LogPrint: console.log,
LogTrace: console.trace,
LogDebug: console.debug,
LogInfo: console.info,
LogWarning: console.warn,
LogError: console.error,
LogFatal: console.error,
// 事件相关方法
EventsOnMultiple: () => console.log('模拟: EventsOnMultiple'),
EventsOn: () => console.log('模拟: EventsOn'),
EventsOff: () => console.log('模拟: EventsOff'),
EventsOnce: () => console.log('模拟: EventsOnce'),
EventsEmit: () => console.log('模拟: EventsEmit'),
// 窗口相关方法
WindowReload: () => console.log('模拟: WindowReload'),
WindowReloadApp: () => console.log('模拟: WindowReloadApp'),
WindowSetAlwaysOnTop: () => console.log('模拟: WindowSetAlwaysOnTop'),
WindowSetSystemDefaultTheme: () => console.log('模拟: WindowSetSystemDefaultTheme'),
WindowSetLightTheme: () => console.log('模拟: WindowSetLightTheme'),
WindowSetDarkTheme: () => console.log('模拟: WindowSetDarkTheme'),
WindowCenter: () => console.log('模拟: WindowCenter'),
WindowSetTitle: () => console.log('模拟: WindowSetTitle'),
WindowFullscreen: () => console.log('模拟: WindowFullscreen'),
WindowUnfullscreen: () => console.log('模拟: WindowUnfullscreen'),
WindowIsFullscreen: () => false,
WindowGetSize: () => ({ width: 800, height: 600 }),
WindowSetSize: () => console.log('模拟: WindowSetSize'),
WindowSetMaxSize: () => console.log('模拟: WindowSetMaxSize'),
WindowSetMinSize: () => console.log('模拟: WindowSetMinSize'),
WindowSetPosition: () => console.log('模拟: WindowSetPosition'),
WindowGetPosition: () => ({ x: 100, y: 100 }),
WindowHide: () => console.log('模拟: WindowHide'),
WindowShow: () => console.log('模拟: WindowShow'),
WindowMaximise: () => console.log('模拟: WindowMaximise'),
WindowToggleMaximise: () => console.log('模拟: WindowToggleMaximise'),
WindowUnmaximise: () => console.log('模拟: WindowUnmaximise'),
WindowIsMaximised: () => false,
WindowMinimise: () => console.log('模拟: WindowMinimise'),
WindowUnminimise: () => console.log('模拟: WindowUnminimise'),
WindowSetBackgroundColour: () => console.log('模拟: WindowSetBackgroundColour'),
WindowIsMinimised: () => false,
WindowIsNormal: () => true,
// 其他功能方法
ScreenGetAll: () => [{ id: 1, width: 1920, height: 1080 }],
BrowserOpenURL: (url) => console.log(`模拟: 打开URL ${url}`),
Environment: () => ({ os: 'browser' }),
Quit: () => console.log('模拟: Quit'),
Hide: () => console.log('模拟: Hide'),
Show: () => console.log('模拟: Show'),
ClipboardGetText: () => '',
ClipboardSetText: (text) => console.log(`模拟: 设置剪贴板文本 ${text}`),
OnFileDrop: () => console.log('模拟: OnFileDrop'),
OnFileDropOff: () => console.log('模拟: OnFileDropOff'),
CanResolveFilePaths: () => false,
ResolveFilePaths: (files) => files
};
}
// 状态变量
const loading = ref(false);
const error = ref('');
const accounts = ref([]);
const diagnostic = ref('');
const startingInstance = ref(false);
// 计算属性,优先使用父组件传入的数据,如果没有则使用本地数据
const displayAccountInfo = computed(() => {
const result = props.accountInfo || localAccountInfo.value || null;
LogFrontend('info', `displayAccountInfo计算结果: ${result ? '有数据' : '无数据'}`);
if (result) {
console.log('displayAccountInfo详情:', result);
}
return result;
});
// 获取企微账号信息
async function fetchAccountInfo() {
LogFrontend('info', '开始获取企微账号信息');
loading.value = true;
error.value = '';
diagnostic.value = '';
try {
LogFrontend('info', '准备调用GetWxWorkAccountList方法');
// 通过主程序加载企微账号列表
if (window.go?.main?.App?.GetWxWorkAccountList) {
const result = await window.go.main.App.GetWxWorkAccountList();
// 处理Wails返回的单个值 - 优化处理逻辑
LogFrontend('info', 'Wails后端返回结果', {
result,
resultType: typeof result,
isArray: Array.isArray(result),
hasSuccess: result && typeof result === 'object' && result.success !== undefined,
hasData: result && typeof result === 'object' && result.data !== undefined
});
// 处理新的统一对象格式
if (result && typeof result === 'object') {
const { success, error: errorMsg, data, diagnostic: diagnosticMsg } = result;
if (success !== false && Array.isArray(data)) {
accounts.value = data;
diagnostic.value = diagnosticMsg || '';
LogFrontend('info', `成功加载 ${data.length} 个企微账号(对象格式)`);
} else {
LogFrontend('warn', errorMsg || '获取数据失败,使用空数组');
accounts.value = [];
diagnostic.value = diagnosticMsg || errorMsg || '';
}
} else if (Array.isArray(result)) {
// 兼容旧格式:直接数组
accounts.value = result;
LogFrontend('info', `成功加载 ${result.length} 个企微账号(数组格式)`);
} else {
LogFrontend('warn', '返回数据格式未知,使用空数组', result);
accounts.value = [];
diagnostic.value = '';
}
} else {
LogFrontend('error', 'Wails方法不可用');
error.value = 'Wails方法不可用';
accounts.value = [];
diagnostic.value = '';
}
} catch (err) {
const errorMessage = err?.message || '加载企微账号信息失败';
LogFrontend('error', `加载过程中出现异常: ${errorMessage}`);
error.value = errorMessage;
accounts.value = [];
diagnostic.value = '';
} finally {
loading.value = false;
}
}
// 处理退出
async function handleLogout(userId) {
LogFrontend('info', `尝试退出企微账号: ${userId}`);
try {
// 找到对应的账号并更新状态
const account = accounts.value.find(acc => acc.user_id === userId);
if (account) {
account.status = 0;
}
LogFrontend('info', `账号 ${userId} 已设置为离线状态`);
const requestData = {
"type": 11112,
"data": {}
};
const jsonData = JSON.stringify(requestData);
// 调用后端SendWxWorkData函数客户端ID暂时使用0
const result = await SendWxWorkData(userId, jsonData);
} catch (err) {
const errorMessage = err && err.message ? err.message : '退出过程中出现异常';
LogFrontend('error', errorMessage);
}
}
// 处理删除
async function handleDelete(userId) {
if (confirm('确定要删除此企微账号吗?')) {
LogFrontend('info', `删除企微账号: ${userId}`);
try {
// 从列表中删除账号
accounts.value = accounts.value.filter(acc => acc.user_id !== userId);
LogFrontend('info', `账号 ${userId} 已从列表中删除`);
// 调用后端方法删除client_status.json中的对应数据
if (window.go?.main?.App?.DeleteWxWorkAccount) {
LogFrontend('info', `调用后端删除client_status.json中的账号: ${userId}`);
const result = await window.go.main.App.DeleteWxWorkAccount(userId);
LogFrontend('info', `后端删除结果: ${result}`);
} else {
LogFrontend('warn', '后端删除方法不可用,仅删除了前端列表中的账号');
}
} catch (err) {
const errorMessage = err && err.message ? err.message : '删除过程中出现异常';
LogFrontend('error', errorMessage);
}
}
}
// 生命周期钩子
async function handleStartNewInstance() {
startingInstance.value = true;
error.value = '';
try {
const result = await StartNewWxWorkInstance();
if (!result?.success) {
error.value = result?.message || '新增企微实例失败';
return;
}
diagnostic.value = `${result.message || '已发送新增企微实例请求'},请在新窗口扫码登录。`;
await fetchAccountInfo();
} catch (err) {
error.value = err?.message || String(err || '新增企微实例失败');
} finally {
startingInstance.value = false;
}
}
onMounted(() => {
fetchAccountInfo();
});
</script>
<style scoped>
.wxwork-account-container {
padding: 20px;
color: #18282d;
}
.account-toolbar-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin: 0 0 16px;
}
.loading {
text-align: center;
padding: 20px;
color: #637379;
}
.diagnostic {
margin-top: 10px;
color: #667085;
font-size: 14px;
line-height: 1.6;
}
.error-message {
color: #9b1c1c;
padding: 10px 12px;
background-color: #fdecec;
border-radius: 6px;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.account-card {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 8px;
border: 1px solid #dfe5e8;
box-shadow: none;
padding: 16px 18px;
width: 100%;
box-sizing: border-box;
}
.avatar-container {
position: relative;
margin-right: 20px;
flex-shrink: 0;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #eef2f4;
}
.status-indicator {
position: absolute;
bottom: 3px;
right: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid white;
}
.status-indicator.online {
background-color: #16706d;
}
.status-indicator.offline {
background-color: #95a5a6;
}
.info-container {
flex: 1;
margin-right: 20px;
}
.name-status {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
h3 {
font-size: 18px;
margin: 0;
color: #18282d;
}
.status-badge {
padding: 3px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: bold;
color: white;
}
.status-badge.online {
background-color: #16706d;
}
.status-badge.offline {
background-color: #95a5a6;
}
.user-id {
color: #637379;
margin: 0;
font-size: 14px;
}
.action-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
min-height: 34px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s, border-color 0.2s;
}
.login-btn {
background-color: #16706d;
color: white;
}
.login-btn:hover {
background-color: #115c5a;
}
.logout-btn {
background-color: #fff;
color: #9b1c1c;
border-color: #f0b8b8;
}
.logout-btn:hover {
background-color: #fdecec;
}
.delete-btn {
background-color: #fff;
color: #8a5a00;
border-color: #ead19a;
}
.delete-btn:hover {
background-color: #fff8e6;
}
.empty-state {
text-align: center;
padding: 40px 0;
color: #637379;
}
/* Command center skin */
.wxwork-account-container {
color: var(--cmd-text);
}
.loading,
.diagnostic,
.user-id,
.empty-state {
color: var(--cmd-text-soft);
}
.account-card {
border-color: var(--cmd-line);
background:
linear-gradient(135deg, rgba(18, 42, 52, 0.92), rgba(8, 20, 26, 0.96)),
var(--cmd-panel);
box-shadow: var(--cmd-shadow);
}
.account-card:hover {
border-color: rgba(72, 240, 220, 0.42);
box-shadow: var(--cmd-shadow), 0 0 24px rgba(72, 240, 220, 0.12);
}
.avatar {
border-color: rgba(72, 240, 220, 0.32);
box-shadow: 0 0 22px rgba(72, 240, 220, 0.12);
}
.status-indicator {
border-color: #071014;
}
.status-indicator.online,
.status-badge.online {
background: var(--cmd-green);
}
.status-indicator.offline,
.status-badge.offline {
background: #6f858e;
}
h3 {
color: var(--cmd-text);
}
.status-badge {
color: #031316;
}
.error-message {
color: var(--cmd-red);
border: 1px solid rgba(255, 107, 125, 0.34);
background: rgba(255, 107, 125, 0.1);
}
.login-btn {
color: #031316;
border-color: rgba(72, 240, 220, 0.82);
background: linear-gradient(135deg, var(--cmd-cyan), var(--cmd-blue));
box-shadow: 0 0 20px rgba(72, 240, 220, 0.16);
}
.login-btn:hover {
background: linear-gradient(135deg, #7ff7e9, #8fc2ff);
}
.logout-btn {
color: #ffd8dd;
border-color: rgba(255, 107, 125, 0.48);
background: rgba(255, 107, 125, 0.1);
}
.logout-btn:hover {
background: rgba(255, 107, 125, 0.16);
}
.delete-btn {
color: var(--cmd-amber);
border-color: rgba(255, 209, 102, 0.42);
background: rgba(255, 209, 102, 0.1);
}
.delete-btn:hover {
background: rgba(255, 209, 102, 0.16);
}
</style>

427
frontend/src/main.js Normal file
View File

@@ -0,0 +1,427 @@
// 浏览器环境下的完整Wails运行时模拟 - 在导入Wails库之前执行
if (typeof window.runtime === 'undefined') {
window.runtime = {
// 日志相关方法 - 生产环境使用新的LogFrontend接口
LogPrint: (message) => {
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('info', message);
} else {
console.log(message);
}
},
LogTrace: (message) => {
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('debug', message);
} else {
console.trace(message);
}
},
LogDebug: (message) => {
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('debug', message);
} else {
console.debug(message);
}
},
LogInfo: (message) => {
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('info', message);
} else {
console.info(message);
}
},
LogWarning: (message) => {
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('warning', message);
} else {
console.warn(message);
}
},
LogError: (message) => {
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('error', message);
} else {
console.error(message);
}
},
LogFatal: (message) => {
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('error', 'FATAL: ' + message);
} else {
console.error('FATAL: ' + message);
}
},
// 事件相关方法
EventsOnMultiple: () => console.log('模拟: EventsOnMultiple'),
EventsOn: () => console.log('模拟: EventsOn'),
EventsOff: () => console.log('模拟: EventsOff'),
EventsOnce: () => console.log('模拟: EventsOnce'),
EventsEmit: () => console.log('模拟: EventsEmit'),
// 窗口相关方法
WindowReload: () => console.log('模拟: WindowReload'),
WindowReloadApp: () => console.log('模拟: WindowReloadApp'),
WindowSetAlwaysOnTop: () => console.log('模拟: WindowSetAlwaysOnTop'),
WindowSetSystemDefaultTheme: () => console.log('模拟: WindowSetSystemDefaultTheme'),
WindowSetLightTheme: () => console.log('模拟: WindowSetLightTheme'),
WindowSetDarkTheme: () => console.log('模拟: WindowSetDarkTheme'),
WindowCenter: () => console.log('模拟: WindowCenter'),
WindowSetTitle: () => console.log('模拟: WindowSetTitle'),
WindowFullscreen: () => console.log('模拟: WindowFullscreen'),
WindowUnfullscreen: () => console.log('模拟: WindowUnfullscreen'),
WindowIsFullscreen: () => false,
WindowGetSize: () => ({ width: 800, height: 600 }),
WindowSetSize: () => console.log('模拟: WindowSetSize'),
WindowSetMaxSize: () => console.log('模拟: WindowSetMaxSize'),
WindowSetMinSize: () => console.log('模拟: WindowSetMinSize'),
WindowSetPosition: () => console.log('模拟: WindowSetPosition'),
WindowGetPosition: () => ({ x: 100, y: 100 }),
WindowHide: () => console.log('模拟: WindowHide'),
WindowShow: () => console.log('模拟: WindowShow'),
WindowMaximise: () => console.log('模拟: WindowMaximise'),
WindowToggleMaximise: () => console.log('模拟: WindowToggleMaximise'),
WindowUnmaximise: () => console.log('模拟: WindowUnmaximise'),
WindowIsMaximised: () => false,
WindowMinimise: () => console.log('模拟: WindowMinimise'),
WindowUnminimise: () => console.log('模拟: WindowUnminimise'),
WindowSetBackgroundColour: () => console.log('模拟: WindowSetBackgroundColour'),
WindowIsMinimised: () => false,
WindowIsNormal: () => true,
// 其他功能方法
ScreenGetAll: () => [{ id: 1, width: 1920, height: 1080 }],
BrowserOpenURL: (url) => console.log(`模拟: 打开URL ${url}`),
Environment: () => ({ os: 'browser' }),
Quit: () => console.log('模拟: Quit'),
Hide: () => console.log('模拟: Hide'),
Show: () => console.log('模拟: Show'),
ClipboardGetText: () => '',
ClipboardSetText: (text) => console.log(`模拟: 设置剪贴板文本 ${text}`),
OnFileDrop: () => console.log('模拟: OnFileDrop'),
OnFileDropOff: () => console.log('模拟: OnFileDropOff'),
CanResolveFilePaths: () => false,
ResolveFilePaths: (files) => files
};
}
// 添加全局LogFrontend函数
window.LogFrontend = (level, message) => {
if (typeof window.go !== 'undefined' && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
return window.go.main.App.LogFrontend(level, message);
} else {
console.log(`[前端-${level.toUpperCase()}] ${message}`);
}
};
// 修改Go函数模拟添加LogFrontend方法
if (typeof window.go === 'undefined') {
window.go = {
main: {
App: {
// 模拟GetCallbackConfig函数
GetCallbackConfig: () => {
console.log('模拟: 获取回调配置');
// 返回模拟数据,格式与后端一致 (bool, interface{})
return [true, {
callbackUrl: '',
callbackToken: '',
httpPort: '10001',
enableCallback: false,
enableCloudAuth: false,
fileUploadUrl: ''
}];
},
// 模拟SaveCallbackConfig函数
SaveCallbackConfig: (jsonData) => {
console.log('模拟: 保存回调配置', jsonData);
// 返回模拟成功结果,格式与后端一致 (bool, string)
return [true, '配置保存成功'];
},
// 模拟SendWxWorkData函数
SendWxWorkData: (clientId, jsonData) => {
console.log('模拟: 发送企微数据', clientId, jsonData);
// 返回模拟成功结果
return [true, null, null];
},
// 模拟GetSystemMemoryUsage函数
GetSystemMemoryUsage: () => {
console.log('模拟: 获取系统内存使用情况');
// 返回模拟内存使用数据
return [true, Math.floor(Math.random() * 40) + 10]; // 10-50%之间的随机值
},
// 模拟LogFrontend函数
LogFrontend: (level, message) => {
console.log(`[前端-${level.toUpperCase()}] ${message}`);
},
GetAutoReplyConfig: () => ({
enabled: false,
listen: {
enablePrivateChat: true,
enableGroupChat: true,
groupTriggerMode: 'mention_only',
ignoreSelfMessage: true,
deduplicateSeconds: 300
},
knowledge: {
directory: 'config/knowledge',
indexPath: 'config/knowledge/index.json',
supportedExtensions: ['.md', '.txt', '.csv', '.xlsx', '.docx', '.pdf'],
topK: 8,
minScore: 0.40
},
retrieval: {
retrievalMode: 'hybrid_rerank',
embeddingIndexPath: 'config/knowledge/embedding_index.json',
embeddingModel: 'text-embedding-v4',
embeddingDimensions: 512,
rerankModel: 'qwen3-rerank',
recallTopK: 50,
rerankTopK: 30,
finalTopK: 8
},
ai: {
provider: 'openai_compatible',
baseUrl: '',
apiKey: '',
model: 'qwen-turbo',
visionModel: 'qwen3-vl-plus',
timeoutSeconds: 20,
enableThinking: false,
replyDetail: 'detailed',
temperature: 0,
maxTokens: 700
},
handoff: {
humanUserId: '',
humanConversationId: '',
messageTemplate: '',
includeKnowledgeHits: true,
sendHumanCardToCustomer: true,
sendCustomerCardToHuman: true,
cardTriggerMode: 'manual_keywords',
manualTriggerKeywords: ['人工', '客服', '转人工', '人工客服', '真人', '真人客服'],
cardKeywords: ['人工', '客服', '转人工', '人工客服', '真人', '真人客服']
},
identity: {
unknownPolicy: 'customer',
unknownHandoffPolicy: 'hold',
refreshOnStart: true,
refreshIntervalMinutes: 30,
pageSize: 200,
internalNoHandoffReply: '内部员工消息不触发转人工,如需协助请直接联系对应同事。',
unknownNoHandoffReply: '正在核验联系人身份,暂不触发转人工。如需协助请直接联系对应同事。',
internalUserIds: [],
externalUserIds: []
},
replyPolicy: {
unknownAnswerToken: 'NO_ANSWER',
maxQuestionLength: 1000,
cooldownSeconds: 3,
sensitiveKeywords: []
}
}),
SaveAutoReplyConfig: (jsonData) => {
console.log('模拟: 保存自动客服配置', jsonData);
return [true, 'success'];
},
SetAutoReplyEnabled: (enabled) => {
console.log('模拟: 设置自动客服开关', enabled);
return [true, 'success'];
},
GetAutoReplyStatus: () => ({
success: true,
data: {
enabled: false,
running: false,
knowledgeFileCount: 0,
knowledgeChunkCount: 0,
retrievalMode: 'hybrid_rerank',
embeddingChunkCount: 0,
embeddingModel: 'text-embedding-v4',
embeddingDimensions: 512,
embeddingLastIndexedAt: 0,
internalContactCount: 0,
externalContactCount: 0,
identityLastRefreshAt: 0,
identityRefreshError: '',
identityRefreshing: false,
identityLastResponseType: '',
identityLastResponseCount: 0,
identityLastResponseAt: 0,
identityLookupInFlight: 0,
todayReceived: 0,
todayReplied: 0,
todayHandoff: 0,
todayIgnored: 0,
todayAIFailed: 0,
lastKnowledgeDurationMs: 0,
lastKeywordDurationMs: 0,
lastVectorDurationMs: 0,
lastRerankDurationMs: 0,
lastAiDurationMs: 0,
lastTotalDurationMs: 0,
lastKeywordScore: 0,
lastVectorScore: 0,
lastRerankScore: 0,
lastMessages: []
}
}),
RebuildKnowledgeIndex: () => ({ success: true, message: 'rebuilt' }),
RefreshAutoReplyContacts: () => ({ success: true, message: 'contact refresh started' }),
GetAutoReplyIdentityOptions: () => ({
success: true,
data: {
internal: [
{ userId: 'engineer-a', name: '张工', source: 'mock', clientId: 1, lastSeenAt: 0 },
{ userId: 'engineer-b', name: '李工', source: 'mock', clientId: 1, lastSeenAt: 0 }
],
external: []
}
}),
GetAutoReplyGroupOptions: () => ({
success: true,
data: [
{ conversationId: 'R:demo-sales', name: '演示售后群', source: 'mock', clientId: 1, lastSeenAt: 0, memberCount: 12 }
]
}),
TestAIConnection: () => ({ success: true, data: { rawSummary: '连接正常', durationMs: 320 } }),
TestHumanHandoff: () => ({ success: true, message: 'sent' }),
GetIssues: () => [],
SaveIssue: () => [true, 'saved'],
DeleteIssue: () => [true, 'deleted'],
GetAfterSalesDispatchConfig: () => ({
success: true,
data: {
engineers: [{ userId: 'engineer-a', name: '张工', description: '负责热成像镜头和图像模糊问题', remark: '镜头组', enabled: true }],
rules: [{ id: 'demo-rule', name: '热成像问题', engineerUserId: 'engineer-a', engineerName: '张工', productKeywords: ['热成像'], issueKeywords: ['报错'], enabled: true }],
notifyTemplate: '',
notifyCooldownSeconds: 300,
autoNotifyEnabled: false,
autoNotifyMinConfidence: 0.75
}
}),
SaveAfterSalesDispatchConfig: () => [true, 'saved'],
GetAfterSalesDispatchQueue: () => ({
success: true,
data: {
issues: [],
summary: { pending: 0, unassigned: 0, assigned: 0, sent: 0, failed: 0, todayNew: 0, notSent: 0 },
config: { engineers: [{ userId: 'engineer-a', name: '张工', description: '负责热成像镜头和图像模糊问题', remark: '镜头组', enabled: true }], rules: [], notifyTemplate: '', notifyCooldownSeconds: 300, autoNotifyEnabled: false, autoNotifyMinConfidence: 0.75 }
}
}),
AssignAfterSalesIssue: () => [true, 'assigned'],
NotifyAfterSalesEngineer: () => ({ success: true, message: 'sent' }),
BatchNotifyAfterSalesEngineers: () => ({ success: true, message: '已成功推送 0/0 条' }),
ResolveAfterSalesIssue: () => ({ success: true, message: '已处理并保存到知识库', data: null }),
ListAfterSalesKnowledgeArchives: () => ({ success: true, message: 'ok', data: [] }),
ListAfterSalesKnowledgeCases: () => ({ success: true, message: 'ok', data: [] }),
UpdateAfterSalesKnowledgeCase: () => ({ success: true, message: '知识案例已更新', data: null }),
RevealAfterSalesKnowledgeCase: () => [true, 'opened'],
GetPendingAfterSalesArchiveSummary: () => ({
success: true,
message: 'ok',
data: { pendingCount: 0, totalCount: 0, archiveCount: 0 }
}),
ArchivePendingAfterSalesIssues: () => ({ success: true, message: '暂无需要保存的问题', data: null }),
RevealAfterSalesKnowledgeArchive: () => [true, 'opened'],
ImportAfterSalesHistory: () => [true, '已同步 2 条历史消息,分析 2 段,新增 1 条售后问题'],
PrepareWeComHistoryCopy: () => [true, '已发送复制命令'],
SyncCurrentWeComChatHistory: () => [true, '已同步 2 条历史消息,分析 2 段,新增 1 条售后问题'],
GetAfterSalesImageData: () => '',
ExportIssuesToExcel: () => [true, 'C:\\tmp\\售后问题库.xlsx'],
GetKingdeeMonitorConfig: () => ({
success: true,
message: 'ok',
data: {
enabled: false,
baseUrl: '',
acctId: '',
username: '',
password: '',
lcid: 2052,
pollIntervalSeconds: 60,
formId: 'SAL_SaleOrder',
billNoFieldKey: 'FBillNo',
orderIdFieldKey: 'FID',
customerFieldKey: 'FCustId.FNumber',
statusFieldKey: '',
completedValue: '排产已完成',
modifyTimeFieldKey: 'FModifyDate',
notifyTemplate: '您好,您的订单 {{billNo}} 已完成排产,后续我们会继续跟进生产进度。',
customerMappings: {}
}
}),
SaveKingdeeMonitorConfig: () => [true, 'saved'],
GetKingdeeMonitorStatus: () => ({
success: true,
message: 'ok',
data: {
running: false,
status: 'stopped',
lastPollAt: 0,
lastCursorTime: '',
lastError: '',
totalPolled: 0,
totalNotified: 0,
totalUnmapped: 0,
notifiedOrders: {},
recentNotices: [],
recentErrors: []
}
}),
TestKingdeeMonitorConnection: () => ({ success: true, message: '金蝶连接正常' }),
RunKingdeeMonitorOnce: () => ({
success: true,
message: '扫描完成',
data: { polled: 0, matched: 0, notified: 0, skipped: 0, unmapped: 0, failed: 0 }
}),
TriggerManualCollect: () => [true, '售后问题收集已开始'],
SetAutoCollectTask: () => [true, 'saved'],
GetAfterSalesIssueStatus: () => ({
success: true,
data: {
autoCollectEnabled: false,
lastCollectAt: 0,
collecting: false,
lastCollectedAt: 0,
lastAddedCount: 0,
lastError: '',
messageBufferCount: 0
}
})
}
}
};
}
import { createApp } from 'vue'
import App from './App.vue'
import { LogInfo } from '../wailsjs/runtime/runtime.js'
// 导入Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// 创建Vue应用实例
const app = createApp(App)
// 使用Element Plus
app.use(ElementPlus)
// 挂载应用到DOM
app.mount('#app')
if (window.go && window.go.main && window.go.main.App && window.go.main.App.LogFrontend) {
window.go.main.App.LogFrontend('info', 'Vue 3 application successfully mounted');
} else {
console.log('Vue 3 application successfully mounted');
}

87
frontend/src/style.css Normal file
View File

@@ -0,0 +1,87 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
color-scheme: dark;
--cmd-bg: #071014;
--cmd-bg-soft: #0b171d;
--cmd-panel: rgba(12, 26, 33, 0.92);
--cmd-panel-strong: rgba(16, 35, 44, 0.96);
--cmd-panel-hover: rgba(21, 48, 59, 0.98);
--cmd-line: rgba(107, 232, 219, 0.22);
--cmd-line-strong: rgba(107, 232, 219, 0.48);
--cmd-text: #e7f6f7;
--cmd-text-soft: #9fb5bd;
--cmd-text-muted: #6f858e;
--cmd-cyan: #48f0dc;
--cmd-cyan-strong: #12c8bd;
--cmd-blue: #63a8ff;
--cmd-violet: #8a7dff;
--cmd-green: #5df2a7;
--cmd-amber: #ffd166;
--cmd-red: #ff6b7d;
--cmd-radius: 8px;
--cmd-shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
--cmd-glow: 0 0 0 1px rgba(72, 240, 220, 0.18), 0 0 28px rgba(72, 240, 220, 0.12);
}
html {
width: 100%;
height: 100%;
background: var(--cmd-bg);
color: var(--cmd-text);
}
body {
width: 100%;
height: 100%;
margin: 0;
color: var(--cmd-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei",
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Helvetica Neue", Arial, sans-serif;
background:
radial-gradient(circle at 24% 12%, rgba(72, 240, 220, 0.16), transparent 34%),
radial-gradient(circle at 88% 2%, rgba(99, 168, 255, 0.14), transparent 30%),
linear-gradient(135deg, #071014 0%, #0a1419 52%, #091018 100%);
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
::selection {
color: #031316;
background: rgba(72, 240, 220, 0.86);
}
::-webkit-scrollbar {
width: 9px;
height: 9px;
}
::-webkit-scrollbar-track {
background: rgba(7, 16, 20, 0.92);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(72, 240, 220, 0.48), rgba(99, 168, 255, 0.38));
border: 2px solid rgba(7, 16, 20, 0.92);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(72, 240, 220, 0.78), rgba(99, 168, 255, 0.62));
}

12
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

115
frontend/wailsjs/go/main/App.d.ts vendored Normal file
View File

@@ -0,0 +1,115 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {main} from '../models';
export function AddLogEntry(arg1:string,arg2:string,arg3:string,arg4:number):Promise<void>;
export function ArchivePendingAfterSalesIssues():Promise<any>;
export function AssignAfterSalesIssue(arg1:string,arg2:string):Promise<boolean|string>;
export function BatchNotifyAfterSalesEngineers(arg1:Array<string>):Promise<any>;
export function DebugLoadLogEntries():Promise<any>;
export function DeleteIssue(arg1:string):Promise<boolean|string>;
export function DeleteWxWorkAccount(arg1:string):Promise<string>;
export function ExportIssuesToExcel():Promise<boolean|string>;
export function GetActiveClientCount():Promise<number>;
export function GetAfterSalesDispatchConfig():Promise<any>;
export function GetAfterSalesDispatchQueue():Promise<any>;
export function GetAfterSalesImageData(arg1:string):Promise<string>;
export function GetAfterSalesIssueStatus():Promise<any>;
export function GetAutoReplyConfig():Promise<any>;
export function GetAutoReplyGroupOptions():Promise<any>;
export function GetAutoReplyIdentityOptions():Promise<any>;
export function GetAutoReplyStatus():Promise<any>;
export function GetCallbackConfig():Promise<any>;
export function GetIssues():Promise<Array<main.AfterSalesIssue>>;
export function GetKingdeeMonitorConfig():Promise<any>;
export function GetKingdeeMonitorStatus():Promise<any>;
export function GetPendingAfterSalesArchiveSummary():Promise<any>;
export function GetSystemMemoryUsage():Promise<number>;
export function GetWxWorkAccountList():Promise<any>;
export function Greet(arg1:string):Promise<string>;
export function ImportAfterSalesHistory(arg1:main.AfterSalesHistoryImportRequest):Promise<boolean|string>;
export function ListAfterSalesKnowledgeArchives():Promise<any>;
export function ListAfterSalesKnowledgeCases():Promise<any>;
export function LogFrontend(arg1:string,arg2:string):Promise<void>;
export function NotifyAfterSalesEngineer(arg1:string):Promise<any>;
export function PrepareWeComHistoryCopy():Promise<boolean|string>;
export function RebuildKnowledgeIndex():Promise<any>;
export function RefreshAutoReplyContacts():Promise<any>;
export function RefreshAutoReplyGroups():Promise<any>;
export function ResolveAfterSalesIssue(arg1:string,arg2:string):Promise<any>;
export function RevealAfterSalesAttachment(arg1:string):Promise<boolean|string>;
export function RevealAfterSalesKnowledgeArchive(arg1:string):Promise<boolean|string>;
export function RevealAfterSalesKnowledgeCase(arg1:string):Promise<boolean|string>;
export function RunKingdeeMonitorOnce():Promise<any>;
export function SaveAfterSalesDispatchConfig(arg1:string):Promise<boolean|string>;
export function SaveAutoReplyConfig(arg1:string):Promise<boolean|string>;
export function SaveCallbackConfig(arg1:string):Promise<boolean|string>;
export function SaveIssue(arg1:main.AfterSalesIssue):Promise<boolean|string>;
export function SaveKingdeeMonitorConfig(arg1:string):Promise<boolean|string>;
export function SendWxWorkData(arg1:string,arg2:string):Promise<boolean>;
export function SetAutoCollectTask(arg1:boolean):Promise<boolean|string>;
export function SetAutoReplyEnabled(arg1:boolean):Promise<boolean|string>;
export function StartNewWxWorkInstance():Promise<any>;
export function SyncAutoReplyInternalGroups():Promise<any>;
export function SyncAutoReplyMaterials():Promise<any>;
export function SyncCurrentWeComChatHistory(arg1:main.AfterSalesHistoryImportRequest):Promise<boolean|string>;
export function TestAIConnection():Promise<any>;
export function TestHumanHandoff():Promise<any>;
export function TestKingdeeMonitorConnection(arg1:string):Promise<any>;
export function TriggerManualCollect(arg1:string):Promise<boolean|string>;
export function UpdateAfterSalesKnowledgeCase(arg1:string,arg2:string):Promise<any>;

View File

@@ -0,0 +1,227 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddLogEntry(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['AddLogEntry'](arg1, arg2, arg3, arg4);
}
export function ArchivePendingAfterSalesIssues() {
return window['go']['main']['App']['ArchivePendingAfterSalesIssues']();
}
export function AssignAfterSalesIssue(arg1, arg2) {
return window['go']['main']['App']['AssignAfterSalesIssue'](arg1, arg2);
}
export function BatchNotifyAfterSalesEngineers(arg1) {
return window['go']['main']['App']['BatchNotifyAfterSalesEngineers'](arg1);
}
export function DebugLoadLogEntries() {
return window['go']['main']['App']['DebugLoadLogEntries']();
}
export function DeleteIssue(arg1) {
return window['go']['main']['App']['DeleteIssue'](arg1);
}
export function DeleteWxWorkAccount(arg1) {
return window['go']['main']['App']['DeleteWxWorkAccount'](arg1);
}
export function ExportIssuesToExcel() {
return window['go']['main']['App']['ExportIssuesToExcel']();
}
export function GetActiveClientCount() {
return window['go']['main']['App']['GetActiveClientCount']();
}
export function GetAfterSalesDispatchConfig() {
return window['go']['main']['App']['GetAfterSalesDispatchConfig']();
}
export function GetAfterSalesDispatchQueue() {
return window['go']['main']['App']['GetAfterSalesDispatchQueue']();
}
export function GetAfterSalesImageData(arg1) {
return window['go']['main']['App']['GetAfterSalesImageData'](arg1);
}
export function GetAfterSalesIssueStatus() {
return window['go']['main']['App']['GetAfterSalesIssueStatus']();
}
export function GetAutoReplyConfig() {
return window['go']['main']['App']['GetAutoReplyConfig']();
}
export function GetAutoReplyGroupOptions() {
return window['go']['main']['App']['GetAutoReplyGroupOptions']();
}
export function GetAutoReplyIdentityOptions() {
return window['go']['main']['App']['GetAutoReplyIdentityOptions']();
}
export function GetAutoReplyStatus() {
return window['go']['main']['App']['GetAutoReplyStatus']();
}
export function GetCallbackConfig() {
return window['go']['main']['App']['GetCallbackConfig']();
}
export function GetIssues() {
return window['go']['main']['App']['GetIssues']();
}
export function GetKingdeeMonitorConfig() {
return window['go']['main']['App']['GetKingdeeMonitorConfig']();
}
export function GetKingdeeMonitorStatus() {
return window['go']['main']['App']['GetKingdeeMonitorStatus']();
}
export function GetPendingAfterSalesArchiveSummary() {
return window['go']['main']['App']['GetPendingAfterSalesArchiveSummary']();
}
export function GetSystemMemoryUsage() {
return window['go']['main']['App']['GetSystemMemoryUsage']();
}
export function GetWxWorkAccountList() {
return window['go']['main']['App']['GetWxWorkAccountList']();
}
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}
export function ImportAfterSalesHistory(arg1) {
return window['go']['main']['App']['ImportAfterSalesHistory'](arg1);
}
export function ListAfterSalesKnowledgeArchives() {
return window['go']['main']['App']['ListAfterSalesKnowledgeArchives']();
}
export function ListAfterSalesKnowledgeCases() {
return window['go']['main']['App']['ListAfterSalesKnowledgeCases']();
}
export function LogFrontend(arg1, arg2) {
return window['go']['main']['App']['LogFrontend'](arg1, arg2);
}
export function NotifyAfterSalesEngineer(arg1) {
return window['go']['main']['App']['NotifyAfterSalesEngineer'](arg1);
}
export function PrepareWeComHistoryCopy() {
return window['go']['main']['App']['PrepareWeComHistoryCopy']();
}
export function RebuildKnowledgeIndex() {
return window['go']['main']['App']['RebuildKnowledgeIndex']();
}
export function RefreshAutoReplyContacts() {
return window['go']['main']['App']['RefreshAutoReplyContacts']();
}
export function RefreshAutoReplyGroups() {
return window['go']['main']['App']['RefreshAutoReplyGroups']();
}
export function ResolveAfterSalesIssue(arg1, arg2) {
return window['go']['main']['App']['ResolveAfterSalesIssue'](arg1, arg2);
}
export function RevealAfterSalesAttachment(arg1) {
return window['go']['main']['App']['RevealAfterSalesAttachment'](arg1);
}
export function RevealAfterSalesKnowledgeArchive(arg1) {
return window['go']['main']['App']['RevealAfterSalesKnowledgeArchive'](arg1);
}
export function RevealAfterSalesKnowledgeCase(arg1) {
return window['go']['main']['App']['RevealAfterSalesKnowledgeCase'](arg1);
}
export function RunKingdeeMonitorOnce() {
return window['go']['main']['App']['RunKingdeeMonitorOnce']();
}
export function SaveAfterSalesDispatchConfig(arg1) {
return window['go']['main']['App']['SaveAfterSalesDispatchConfig'](arg1);
}
export function SaveAutoReplyConfig(arg1) {
return window['go']['main']['App']['SaveAutoReplyConfig'](arg1);
}
export function SaveCallbackConfig(arg1) {
return window['go']['main']['App']['SaveCallbackConfig'](arg1);
}
export function SaveIssue(arg1) {
return window['go']['main']['App']['SaveIssue'](arg1);
}
export function SaveKingdeeMonitorConfig(arg1) {
return window['go']['main']['App']['SaveKingdeeMonitorConfig'](arg1);
}
export function SendWxWorkData(arg1, arg2) {
return window['go']['main']['App']['SendWxWorkData'](arg1, arg2);
}
export function SetAutoCollectTask(arg1) {
return window['go']['main']['App']['SetAutoCollectTask'](arg1);
}
export function SetAutoReplyEnabled(arg1) {
return window['go']['main']['App']['SetAutoReplyEnabled'](arg1);
}
export function StartNewWxWorkInstance() {
return window['go']['main']['App']['StartNewWxWorkInstance']();
}
export function SyncAutoReplyInternalGroups() {
return window['go']['main']['App']['SyncAutoReplyInternalGroups']();
}
export function SyncAutoReplyMaterials() {
return window['go']['main']['App']['SyncAutoReplyMaterials']();
}
export function SyncCurrentWeComChatHistory(arg1) {
return window['go']['main']['App']['SyncCurrentWeComChatHistory'](arg1);
}
export function TestAIConnection() {
return window['go']['main']['App']['TestAIConnection']();
}
export function TestHumanHandoff() {
return window['go']['main']['App']['TestHumanHandoff']();
}
export function TestKingdeeMonitorConnection(arg1) {
return window['go']['main']['App']['TestKingdeeMonitorConnection'](arg1);
}
export function TriggerManualCollect(arg1) {
return window['go']['main']['App']['TriggerManualCollect'](arg1);
}
export function UpdateAfterSalesKnowledgeCase(arg1, arg2) {
return window['go']['main']['App']['UpdateAfterSalesKnowledgeCase'](arg1, arg2);
}

View File

@@ -0,0 +1,143 @@
export namespace main {
export class AfterSalesFileAttachment {
name: string;
path: string;
ref: string;
content: string;
extractStatus: string;
sourceMessageId: string;
static createFrom(source: any = {}) {
return new AfterSalesFileAttachment(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.path = source["path"];
this.ref = source["ref"];
this.content = source["content"];
this.extractStatus = source["extractStatus"];
this.sourceMessageId = source["sourceMessageId"];
}
}
export class AfterSalesHistoryImportRequest {
conversationId: string;
roomName: string;
rawText: string;
static createFrom(source: any = {}) {
return new AfterSalesHistoryImportRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.conversationId = source["conversationId"];
this.roomName = source["roomName"];
this.rawText = source["rawText"];
}
}
export class AfterSalesIssue {
id: string;
createdAt: string;
updatedAt: string;
conversationId: string;
roomName: string;
sourceClientId: number;
sourceAccountUserId: string;
sourceAccountName: string;
customerUserId: string;
customerName: string;
issueContent: string;
imagePaths: string[];
imageRefs: string[];
fileAttachments: AfterSalesFileAttachment[];
aiSuggestion: string;
status: string;
sourceMessageIds: string[];
fingerprint: string;
collectBatchId: string;
aiConfidence: number;
aiSuggestionEdited: boolean;
assignedEngineerId: string;
assignedEngineerName: string;
dispatchStatus: string;
dispatchReason: string;
dispatchRuleId: string;
dispatchConfidence: number;
dispatchSource: string;
notifyStatus: string;
lastNotifiedAt: number;
notifyError: string;
notifyCount: number;
resolutionContent: string;
resolvedAt: string;
knowledgeArchivedAt: string;
knowledgeSourcePath: string;
static createFrom(source: any = {}) {
return new AfterSalesIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
this.conversationId = source["conversationId"];
this.roomName = source["roomName"];
this.sourceClientId = source["sourceClientId"];
this.sourceAccountUserId = source["sourceAccountUserId"];
this.sourceAccountName = source["sourceAccountName"];
this.customerUserId = source["customerUserId"];
this.customerName = source["customerName"];
this.issueContent = source["issueContent"];
this.imagePaths = source["imagePaths"];
this.imageRefs = source["imageRefs"];
this.fileAttachments = this.convertValues(source["fileAttachments"], AfterSalesFileAttachment);
this.aiSuggestion = source["aiSuggestion"];
this.status = source["status"];
this.sourceMessageIds = source["sourceMessageIds"];
this.fingerprint = source["fingerprint"];
this.collectBatchId = source["collectBatchId"];
this.aiConfidence = source["aiConfidence"];
this.aiSuggestionEdited = source["aiSuggestionEdited"];
this.assignedEngineerId = source["assignedEngineerId"];
this.assignedEngineerName = source["assignedEngineerName"];
this.dispatchStatus = source["dispatchStatus"];
this.dispatchReason = source["dispatchReason"];
this.dispatchRuleId = source["dispatchRuleId"];
this.dispatchConfidence = source["dispatchConfidence"];
this.dispatchSource = source["dispatchSource"];
this.notifyStatus = source["notifyStatus"];
this.lastNotifiedAt = source["lastNotifiedAt"];
this.notifyError = source["notifyError"];
this.notifyCount = source["notifyCount"];
this.resolutionContent = source["resolutionContent"];
this.resolvedAt = source["resolvedAt"];
this.knowledgeArchivedAt = source["knowledgeArchivedAt"];
this.knowledgeSourcePath = source["knowledgeSourcePath"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

249
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,249 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void

View File

@@ -0,0 +1,238 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}

46
go.mod Normal file
View File

@@ -0,0 +1,46 @@
module qiweimanager
go 1.24.1
require (
github.com/google/uuid v1.6.0
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
github.com/wailsapp/wails/v2 v2.10.2
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
)
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.19 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.10.2 => C:\Users\lenovo\go\pkg\mod

97
go.sum Normal file
View File

@@ -0,0 +1,97 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk=
github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
helper.exe Normal file

Binary file not shown.

149
helper/after_sales_ai.go Normal file
View File

@@ -0,0 +1,149 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"qiweimanager/config"
)
func callAfterSalesAI(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
if len(messages) == 0 {
return nil, nil
}
aiCfg.MaxTokens = maxInt(aiCfg.MaxTokens, 1200)
aiCfg.TimeoutSeconds = maxInt(aiCfg.TimeoutSeconds, 30)
systemPrompt := buildAfterSalesSystemPrompt()
userPrompt := buildAfterSalesUserPrompt(messages)
var result *AIResult
var err error
switch strings.ToLower(strings.TrimSpace(aiCfg.Provider)) {
case "local", "ollama":
result, err = callOllamaChat(aiCfg, systemPrompt, userPrompt)
default:
result, err = callOpenAICompatibleChat(aiCfg, systemPrompt, userPrompt)
}
if err != nil {
return nil, err
}
return parseAfterSalesAIResponse(result.Answer)
}
func buildAfterSalesSystemPrompt() string {
return strings.Join([]string{
"You are an after-sales issue extraction assistant.",
"Find unresolved customer issues from WeCom group chat records.",
"Ignore greetings, small talk, resolved questions, and internal staff messages that are not customer issues.",
"Customers are usually external or unknown; internal messages are usually staff.",
"If text plus later images or files describe one issue together, include the related source_message_ids.",
"Split unrelated devices, failures, requests, or time periods into separate JSON objects.",
"Return only a JSON array. Do not return markdown or explanations.",
"Each object must contain room_name, customer_user_id, customer_name, issue_content, image_paths, image_refs, ai_suggestion, source_message_ids, confidence.",
}, "\n")
}
func buildAfterSalesUserPrompt(messages []AfterSalesMessage) string {
items := append([]AfterSalesMessage(nil), messages...)
sort.Slice(items, func(i, j int) bool { return items[i].SendTime < items[j].SendTime })
var b strings.Builder
roomName := ""
if len(items) > 0 {
roomName = items[0].RoomName
}
b.WriteString("Group: ")
b.WriteString(firstNonEmpty(roomName, "unknown group"))
b.WriteString("\nChat records:\n")
for _, msg := range items {
tm := time.Unix(msg.SendTime, 0).Local().Format("2006-01-02 15:04")
b.WriteString("- id=")
b.WriteString(msg.MessageID)
b.WriteString(" time=")
b.WriteString(tm)
b.WriteString(" role=")
b.WriteString(firstNonEmpty(msg.SenderIdentity, senderIdentityUnknown))
b.WriteString(" user_id=")
b.WriteString(msg.SenderUserID)
b.WriteString(" name=")
b.WriteString(firstNonEmpty(msg.SenderName, "unknown"))
b.WriteString(": ")
content := strings.TrimSpace(msg.Content)
if content != "" {
b.WriteString(content)
}
if msg.ImagePath != "" {
b.WriteString(" [image:")
b.WriteString(msg.ImagePath)
b.WriteString("]")
}
if msg.ImageRef != "" {
b.WriteString(" [image_ref:")
b.WriteString(msg.ImageRef)
b.WriteString("]")
}
if msg.FilePath != "" || msg.FileRef != "" || msg.FileName != "" || msg.FileContent != "" {
b.WriteString(" [file:")
b.WriteString(firstNonEmpty(msg.FileName, msg.FilePath, msg.FileRef))
if msg.FileExtractStatus != "" {
b.WriteString(" status=")
b.WriteString(msg.FileExtractStatus)
}
b.WriteString("]")
}
b.WriteString("\n")
if msg.FileContent != "" {
b.WriteString(" file_content: ")
b.WriteString(truncateText(msg.FileContent, afterSalesFilePromptLimit))
b.WriteString("\n")
}
}
b.WriteString("\nExtract unresolved after-sales issues. source_message_ids must reference ids above. If there is no unresolved issue, return [].")
return b.String()
}
func parseAfterSalesAIResponse(text string) ([]afterSalesAIIssueCandidate, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, fmt.Errorf("AI returned empty response")
}
text = stripJSONMarkdownFence(text)
start := strings.Index(text, "[")
end := strings.LastIndex(text, "]")
if start < 0 || end < start {
return nil, fmt.Errorf("AI did not return a JSON array: %s", truncateText(text, 200))
}
payload := text[start : end+1]
var result []afterSalesAIIssueCandidate
if err := json.Unmarshal([]byte(payload), &result); err != nil {
return nil, fmt.Errorf("parse AI JSON failed: %w", err)
}
for i := range result {
result[i].IssueContent = strings.TrimSpace(result[i].IssueContent)
result[i].CustomerName = strings.TrimSpace(result[i].CustomerName)
result[i].CustomerUserID = strings.TrimSpace(result[i].CustomerUserID)
result[i].RoomName = strings.TrimSpace(result[i].RoomName)
result[i].AISuggestion = strings.TrimSpace(result[i].AISuggestion)
result[i].ImagePaths = uniqueNonEmptyStrings(result[i].ImagePaths)
result[i].ImageRefs = uniqueNonEmptyStrings(result[i].ImageRefs)
result[i].SourceMessageIDs = uniqueNonEmptyStrings(result[i].SourceMessageIDs)
}
return result, nil
}
func stripJSONMarkdownFence(text string) string {
text = strings.TrimSpace(text)
fence := string(rune(96)) + string(rune(96)) + string(rune(96))
if !strings.HasPrefix(text, fence) {
return text
}
lines := strings.Split(text, "\n")
if len(lines) <= 2 {
return text
}
if strings.HasPrefix(strings.TrimSpace(lines[0]), fence) && strings.HasPrefix(strings.TrimSpace(lines[len(lines)-1]), fence) {
return strings.TrimSpace(strings.Join(lines[1:len(lines)-1], "\n"))
}
return text
}

View File

@@ -0,0 +1,873 @@
package main
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"time"
"qiweimanager/config"
)
const (
afterSalesDispatchUnassigned = "unassigned"
afterSalesDispatchSuggested = "suggested"
afterSalesDispatchAssigned = "assigned"
afterSalesNotifyNotSent = "not_sent"
afterSalesNotifySent = "sent"
afterSalesNotifyFailed = "failed"
defaultDispatchNotifyCooldownSeconds = 300
defaultDispatchMinConfidence = 0.75
dispatchSourceAI = "ai_suggested"
dispatchSourceManual = "manual_assigned"
dispatchSourceRule = "rule_suggested"
)
type AfterSalesEngineer struct {
UserID string `json:"userId"`
Name string `json:"name"`
Description string `json:"description"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
}
type AfterSalesDispatchRule struct {
ID string `json:"id"`
Name string `json:"name"`
EngineerUserID string `json:"engineerUserId"`
EngineerName string `json:"engineerName"`
ConversationIDs []string `json:"conversationIds"`
CustomerNames []string `json:"customerNames"`
ProductKeywords []string `json:"productKeywords"`
IssueKeywords []string `json:"issueKeywords"`
Enabled bool `json:"enabled"`
}
type AfterSalesDispatchConfig struct {
Engineers []AfterSalesEngineer `json:"engineers"`
Rules []AfterSalesDispatchRule `json:"rules"`
NotifyTemplate string `json:"notifyTemplate"`
NotifyCooldownSeconds int `json:"notifyCooldownSeconds"`
AutoNotifyEnabled bool `json:"autoNotifyEnabled"`
AutoNotifyMinConfidence float64 `json:"autoNotifyMinConfidence"`
}
type AfterSalesDispatchSummary struct {
Pending int `json:"pending"`
Unassigned int `json:"unassigned"`
Assigned int `json:"assigned"`
Sent int `json:"sent"`
Failed int `json:"failed"`
TodayNew int `json:"todayNew"`
NotSent int `json:"notSent"`
}
type AfterSalesDispatchQueue struct {
Issues []AfterSalesIssue `json:"issues"`
Summary AfterSalesDispatchSummary `json:"summary"`
Config AfterSalesDispatchConfig `json:"config"`
}
type AfterSalesNotifyResult struct {
IssueID string `json:"issueId"`
Success bool `json:"success"`
Message string `json:"message"`
}
type afterSalesDispatchMatch struct {
Rule AfterSalesDispatchRule
Priority int
MatchedLabel string
}
type afterSalesDispatchAIChoice struct {
EngineerUserID string `json:"engineer_user_id"`
Reason string `json:"reason"`
Confidence float64 `json:"confidence"`
}
var (
afterSalesDispatchMatcher = callAfterSalesDispatchAI
afterSalesDispatchAIConfigSource = currentAfterSalesDispatchAIConfig
)
func defaultAfterSalesDispatchConfig() AfterSalesDispatchConfig {
return AfterSalesDispatchConfig{
Engineers: []AfterSalesEngineer{},
Rules: []AfterSalesDispatchRule{},
NotifyTemplate: "",
NotifyCooldownSeconds: defaultDispatchNotifyCooldownSeconds,
AutoNotifyEnabled: false,
AutoNotifyMinConfidence: defaultDispatchMinConfidence,
}
}
func afterSalesDispatchConfigPath() string {
return resolveAutoReplyPath("config/after_sales_dispatch_config.json")
}
func readAfterSalesDispatchConfig() (AfterSalesDispatchConfig, error) {
cfg := defaultAfterSalesDispatchConfig()
if err := readJSONFile(afterSalesDispatchConfigPath(), &cfg); err != nil {
return cfg, err
}
normalizeAfterSalesDispatchConfig(&cfg)
return cfg, nil
}
func saveAfterSalesDispatchConfig(cfg AfterSalesDispatchConfig) error {
normalizeAfterSalesDispatchConfig(&cfg)
return atomicWriteJSON(afterSalesDispatchConfigPath(), cfg)
}
func normalizeAfterSalesDispatchConfig(cfg *AfterSalesDispatchConfig) {
if cfg == nil {
return
}
if cfg.NotifyCooldownSeconds <= 0 {
cfg.NotifyCooldownSeconds = defaultDispatchNotifyCooldownSeconds
}
if cfg.AutoNotifyMinConfidence <= 0 || cfg.AutoNotifyMinConfidence > 1 {
cfg.AutoNotifyMinConfidence = defaultDispatchMinConfidence
}
for i := range cfg.Engineers {
cfg.Engineers[i].UserID = strings.TrimSpace(cfg.Engineers[i].UserID)
cfg.Engineers[i].Name = strings.TrimSpace(cfg.Engineers[i].Name)
cfg.Engineers[i].Description = strings.TrimSpace(cfg.Engineers[i].Description)
cfg.Engineers[i].Remark = strings.TrimSpace(cfg.Engineers[i].Remark)
if !cfg.Engineers[i].Enabled && cfg.Engineers[i].UserID != "" {
cfg.Engineers[i].Enabled = true
}
}
cfg.Engineers = uniqueAfterSalesEngineers(cfg.Engineers)
for i := range cfg.Rules {
rule := &cfg.Rules[i]
rule.ID = strings.TrimSpace(rule.ID)
if rule.ID == "" {
rule.ID = newAfterSalesID()
}
rule.Name = strings.TrimSpace(rule.Name)
rule.EngineerUserID = strings.TrimSpace(rule.EngineerUserID)
rule.EngineerName = strings.TrimSpace(rule.EngineerName)
rule.ConversationIDs = uniqueNonEmptyStrings(rule.ConversationIDs)
rule.CustomerNames = uniqueNonEmptyStrings(rule.CustomerNames)
rule.ProductKeywords = uniqueNonEmptyStrings(rule.ProductKeywords)
rule.IssueKeywords = uniqueNonEmptyStrings(rule.IssueKeywords)
if !rule.Enabled && rule.EngineerUserID != "" {
rule.Enabled = true
}
}
}
func uniqueAfterSalesEngineers(items []AfterSalesEngineer) []AfterSalesEngineer {
seen := make(map[string]bool)
result := make([]AfterSalesEngineer, 0, len(items))
for _, item := range items {
item.UserID = strings.TrimSpace(item.UserID)
if item.UserID == "" || seen[item.UserID] {
continue
}
seen[item.UserID] = true
result = append(result, item)
}
return result
}
func normalizeAfterSalesDispatchFields(issue *AfterSalesIssue) {
if issue == nil {
return
}
issue.AssignedEngineerID = strings.TrimSpace(issue.AssignedEngineerID)
issue.AssignedEngineerName = strings.TrimSpace(issue.AssignedEngineerName)
issue.DispatchStatus = normalizeAfterSalesDispatchStatus(issue.DispatchStatus, issue.AssignedEngineerID)
issue.NotifyStatus = normalizeAfterSalesNotifyStatus(issue.NotifyStatus)
issue.DispatchReason = strings.TrimSpace(issue.DispatchReason)
issue.DispatchRuleID = strings.TrimSpace(issue.DispatchRuleID)
issue.DispatchSource = strings.TrimSpace(issue.DispatchSource)
if issue.DispatchConfidence < 0 {
issue.DispatchConfidence = 0
}
if issue.DispatchConfidence > 1 {
issue.DispatchConfidence = 1
}
issue.NotifyError = strings.TrimSpace(issue.NotifyError)
if issue.NotifyStatus == afterSalesNotifySent {
issue.NotifyError = ""
}
}
func normalizeAfterSalesDispatchStatus(status string, engineerID string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case afterSalesDispatchSuggested:
if strings.TrimSpace(engineerID) != "" {
return afterSalesDispatchSuggested
}
case afterSalesDispatchAssigned:
if strings.TrimSpace(engineerID) != "" {
return afterSalesDispatchAssigned
}
}
return afterSalesDispatchUnassigned
}
func normalizeAfterSalesNotifyStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case afterSalesNotifySent:
return afterSalesNotifySent
case afterSalesNotifyFailed:
return afterSalesNotifyFailed
default:
return afterSalesNotifyNotSent
}
}
func (e *AfterSalesIssueEngine) dispatchQueue() (AfterSalesDispatchQueue, error) {
cfg, err := readAfterSalesDispatchConfig()
if err != nil {
return AfterSalesDispatchQueue{}, err
}
e.refreshDispatchAssignments(cfg)
e.mu.Lock()
issues := append([]AfterSalesIssue(nil), e.issues...)
e.mu.Unlock()
sort.Slice(issues, func(i, j int) bool {
return issues[i].CreatedAt > issues[j].CreatedAt
})
return AfterSalesDispatchQueue{
Issues: issues,
Summary: summarizeAfterSalesDispatch(issues),
Config: cfg,
}, nil
}
func (e *AfterSalesIssueEngine) refreshDispatchAssignments(cfg AfterSalesDispatchConfig) {
candidates := e.dispatchAssignmentCandidates(cfg)
for _, issue := range candidates {
choice, err := dispatchIssueWithAI(cfg, issue)
e.applyDispatchChoice(issue.ID, cfg, choice, err)
}
if cfg.AutoNotifyEnabled {
for _, issueID := range e.autoNotifyCandidateIDs(cfg) {
e.notifyEngineer(issueID)
}
}
}
func (e *AfterSalesIssueEngine) refreshDispatchAssignmentsAsync() {
go func() {
cfg, err := readAfterSalesDispatchConfig()
if err != nil {
if globalLogger != nil {
globalLogger.Warn("[工程师派单] 加载配置失败: %v", err)
}
return
}
e.refreshDispatchAssignments(cfg)
}()
}
func (e *AfterSalesIssueEngine) dispatchAssignmentCandidates(cfg AfterSalesDispatchConfig) []AfterSalesIssue {
e.mu.Lock()
defer e.mu.Unlock()
changed := e.applyLegacyDispatchSuggestionsLocked(cfg)
if changed {
_ = e.saveIssuesLocked()
}
result := make([]AfterSalesIssue, 0)
for _, issue := range e.issues {
if shouldDispatchWithAI(issue, cfg) {
result = append(result, issue)
}
}
return result
}
func shouldDispatchWithAI(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) bool {
if issue.Status != afterSalesIssueStatusPending {
return false
}
if issue.DispatchSource == dispatchSourceManual || issue.DispatchStatus == afterSalesDispatchAssigned {
return false
}
if issue.NotifyStatus == afterSalesNotifySent {
return false
}
if len(enabledDispatchEngineers(cfg)) == 0 {
return false
}
return true
}
func enabledDispatchEngineers(cfg AfterSalesDispatchConfig) []AfterSalesEngineer {
result := make([]AfterSalesEngineer, 0, len(cfg.Engineers))
for _, engineer := range cfg.Engineers {
if !engineer.Enabled || strings.TrimSpace(engineer.UserID) == "" {
continue
}
if strings.TrimSpace(engineer.Description) == "" {
continue
}
result = append(result, engineer)
}
return result
}
func dispatchIssueWithAI(cfg AfterSalesDispatchConfig, issue AfterSalesIssue) (afterSalesDispatchAIChoice, error) {
aiCfg, err := afterSalesDispatchAIConfigSource()
if err != nil {
return afterSalesDispatchAIChoice{}, err
}
return afterSalesDispatchMatcher(aiCfg, issue, enabledDispatchEngineers(cfg))
}
func currentAfterSalesDispatchAIConfig() (config.AIConfig, error) {
appConfig := config.GetGlobalConfig()
if appConfig == nil {
return config.AIConfig{}, fmt.Errorf("AI 配置未加载")
}
appConfig.ApplyDefaults()
aiCfg := appConfig.AutoReplyConfig.AI
if strings.TrimSpace(aiCfg.BaseURL) == "" || strings.TrimSpace(aiCfg.Model) == "" {
return config.AIConfig{}, fmt.Errorf("AI 配置未完整")
}
return aiCfg, nil
}
func (e *AfterSalesIssueEngine) applyDispatchChoice(issueID string, cfg AfterSalesDispatchConfig, choice afterSalesDispatchAIChoice, matchErr error) {
e.mu.Lock()
defer e.mu.Unlock()
for i := range e.issues {
issue := &e.issues[i]
if issue.ID != issueID {
continue
}
before := *issue
normalizeAfterSalesDispatchFields(issue)
if !shouldDispatchWithAI(*issue, cfg) {
return
}
if matchErr != nil {
clearDispatchSuggestion(issue, "AI匹配失败: "+matchErr.Error())
} else if choice.Confidence < cfg.AutoNotifyMinConfidence || strings.TrimSpace(choice.EngineerUserID) == "" {
clearDispatchSuggestion(issue, strings.TrimSpace(firstNonEmpty(choice.Reason, "AI置信度低需人工确认")))
issue.DispatchConfidence = choice.Confidence
issue.DispatchSource = dispatchSourceAI
} else {
issue.AssignedEngineerID = strings.TrimSpace(choice.EngineerUserID)
issue.AssignedEngineerName = dispatchEngineerName(choice.EngineerUserID, "", cfg)
issue.DispatchStatus = afterSalesDispatchSuggested
issue.DispatchReason = strings.TrimSpace(choice.Reason)
if issue.DispatchReason == "" {
issue.DispatchReason = "AI根据工程师职责说明匹配"
}
issue.DispatchRuleID = ""
issue.DispatchConfidence = choice.Confidence
issue.DispatchSource = dispatchSourceAI
}
if !reflect.DeepEqual(*issue, before) {
issue.UpdatedAt = time.Now().Local().Format(time.RFC3339)
_ = e.saveIssuesLocked()
}
return
}
}
func clearDispatchSuggestion(issue *AfterSalesIssue, reason string) {
issue.AssignedEngineerID = ""
issue.AssignedEngineerName = ""
issue.DispatchStatus = afterSalesDispatchUnassigned
issue.DispatchReason = strings.TrimSpace(reason)
issue.DispatchRuleID = ""
}
func (e *AfterSalesIssueEngine) autoNotifyCandidateIDs(cfg AfterSalesDispatchConfig) []string {
e.mu.Lock()
defer e.mu.Unlock()
result := make([]string, 0)
for _, issue := range e.issues {
if issue.Status != afterSalesIssueStatusPending {
continue
}
if issue.NotifyStatus != afterSalesNotifyNotSent {
continue
}
if issue.DispatchSource != dispatchSourceAI || issue.DispatchConfidence < cfg.AutoNotifyMinConfidence {
continue
}
if strings.TrimSpace(issue.AssignedEngineerID) == "" {
continue
}
result = append(result, issue.ID)
}
return result
}
func summarizeAfterSalesDispatch(issues []AfterSalesIssue) AfterSalesDispatchSummary {
var summary AfterSalesDispatchSummary
today := time.Now().Local().Format("2006-01-02")
for _, issue := range issues {
if issue.Status != afterSalesIssueStatusPending {
continue
}
summary.Pending++
if strings.HasPrefix(issue.CreatedAt, today) {
summary.TodayNew++
}
switch normalizeAfterSalesDispatchStatus(issue.DispatchStatus, issue.AssignedEngineerID) {
case afterSalesDispatchAssigned, afterSalesDispatchSuggested:
summary.Assigned++
default:
summary.Unassigned++
}
switch normalizeAfterSalesNotifyStatus(issue.NotifyStatus) {
case afterSalesNotifySent:
summary.Sent++
case afterSalesNotifyFailed:
summary.Failed++
default:
summary.NotSent++
}
}
return summary
}
func (e *AfterSalesIssueEngine) applyLegacyDispatchSuggestionsLocked(cfg AfterSalesDispatchConfig) bool {
changed := false
for i := range e.issues {
issue := &e.issues[i]
before := *issue
normalizeAfterSalesDispatchFields(issue)
if issue.Status == afterSalesIssueStatusPending &&
issue.DispatchStatus != afterSalesDispatchAssigned &&
issue.DispatchSource != dispatchSourceManual &&
len(enabledDispatchEngineers(cfg)) == 0 {
if match, ok := matchAfterSalesDispatchRule(*issue, cfg); ok {
issue.AssignedEngineerID = strings.TrimSpace(match.Rule.EngineerUserID)
issue.AssignedEngineerName = dispatchEngineerName(match.Rule.EngineerUserID, match.Rule.EngineerName, cfg)
issue.DispatchStatus = afterSalesDispatchSuggested
issue.DispatchReason = match.MatchedLabel
issue.DispatchRuleID = match.Rule.ID
issue.DispatchConfidence = 1
issue.DispatchSource = dispatchSourceRule
} else if issue.DispatchStatus == afterSalesDispatchSuggested {
issue.AssignedEngineerID = ""
issue.AssignedEngineerName = ""
issue.DispatchStatus = afterSalesDispatchUnassigned
issue.DispatchReason = ""
issue.DispatchRuleID = ""
issue.DispatchConfidence = 0
issue.DispatchSource = ""
}
}
if !reflect.DeepEqual(*issue, before) {
changed = true
}
}
return changed
}
func matchAfterSalesDispatchRule(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) (afterSalesDispatchMatch, bool) {
best := afterSalesDispatchMatch{}
found := false
for _, rule := range cfg.Rules {
if !rule.Enabled || strings.TrimSpace(rule.EngineerUserID) == "" {
continue
}
if priority, label := dispatchRuleMatchPriority(issue, rule); priority > 0 {
if !found || priority > best.Priority {
best = afterSalesDispatchMatch{Rule: rule, Priority: priority, MatchedLabel: label}
found = true
}
}
}
return best, found
}
func callAfterSalesDispatchAI(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) {
if len(engineers) == 0 {
return afterSalesDispatchAIChoice{}, fmt.Errorf("未配置工程师职责说明")
}
aiCfg.MaxTokens = maxInt(aiCfg.MaxTokens, 700)
aiCfg.TimeoutSeconds = maxInt(aiCfg.TimeoutSeconds, 20)
systemPrompt := `你是售后问题派单助手。请根据售后问题内容和工程师职责说明,选择最应该处理该问题的一位工程师。
规则:
1. 只能从给定工程师列表中选择 user_id。
2. 如果没有明确匹配engineer_user_id 输出空字符串confidence 不高于 0.5。
3. confidence 是 0 到 1 的数字,越确定越接近 1。
4. 只输出 JSON 对象,不要 markdown不要解释文字。
JSON 字段engineer_user_id, reason, confidence。`
userPrompt := buildAfterSalesDispatchPrompt(issue, engineers)
var result *AIResult
var err error
switch strings.ToLower(strings.TrimSpace(aiCfg.Provider)) {
case "local", "ollama":
result, err = callOllamaChat(aiCfg, systemPrompt, userPrompt)
default:
result, err = callOpenAICompatibleChat(aiCfg, systemPrompt, userPrompt)
}
if err != nil {
return afterSalesDispatchAIChoice{}, err
}
choice, err := parseAfterSalesDispatchAIResponse(result.Answer)
if err != nil {
return afterSalesDispatchAIChoice{}, err
}
if choice.EngineerUserID != "" && !dispatchEngineerExists(choice.EngineerUserID, engineers) {
return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 返回了未配置的工程师: %s", choice.EngineerUserID)
}
return choice, nil
}
func buildAfterSalesDispatchPrompt(issue AfterSalesIssue, engineers []AfterSalesEngineer) string {
var b strings.Builder
b.WriteString("售后问题:\n")
b.WriteString("问题ID")
b.WriteString(issue.ID)
b.WriteString("\n群聊")
b.WriteString(firstNonEmpty(issue.RoomName, issue.ConversationID))
b.WriteString("\n客户")
b.WriteString(normalizeAfterSalesDisplayName(issue.CustomerName))
b.WriteString("\n问题描述")
b.WriteString(strings.TrimSpace(issue.IssueContent))
b.WriteString("\nAI建议")
b.WriteString(strings.TrimSpace(issue.AISuggestion))
b.WriteString("\n图片数量")
b.WriteString(fmt.Sprintf("%d", len(issue.ImagePaths)+len(issue.ImageRefs)))
b.WriteString("\n\n工程师列表\n")
for _, engineer := range engineers {
b.WriteString("- user_id=")
b.WriteString(engineer.UserID)
b.WriteString(" name=")
b.WriteString(firstNonEmpty(engineer.Name, engineer.UserID))
if engineer.Remark != "" {
b.WriteString(" remark=")
b.WriteString(engineer.Remark)
}
b.WriteString("\n 职责说明:")
b.WriteString(engineer.Description)
b.WriteString("\n")
}
b.WriteString("\n请选择最匹配的一位工程师。无法明确判断时返回空 engineer_user_id。")
return b.String()
}
func parseAfterSalesDispatchAIResponse(text string) (afterSalesDispatchAIChoice, error) {
text = strings.TrimSpace(stripJSONMarkdownFence(text))
if text == "" {
return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 返回为空")
}
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start < 0 || end < start {
return afterSalesDispatchAIChoice{}, fmt.Errorf("AI 未返回 JSON 对象: %s", truncateText(text, 200))
}
var choice afterSalesDispatchAIChoice
if err := json.Unmarshal([]byte(text[start:end+1]), &choice); err != nil {
return afterSalesDispatchAIChoice{}, fmt.Errorf("解析派单 AI JSON 失败: %w", err)
}
choice.EngineerUserID = strings.TrimSpace(choice.EngineerUserID)
choice.Reason = strings.TrimSpace(choice.Reason)
if choice.Confidence < 0 {
choice.Confidence = 0
}
if choice.Confidence > 1 {
choice.Confidence = 1
}
return choice, nil
}
func dispatchEngineerExists(userID string, engineers []AfterSalesEngineer) bool {
userID = strings.TrimSpace(userID)
for _, engineer := range engineers {
if strings.TrimSpace(engineer.UserID) == userID {
return true
}
}
return false
}
func dispatchRuleMatchPriority(issue AfterSalesIssue, rule AfterSalesDispatchRule) (int, string) {
if containsAnyNormalized(rule.ConversationIDs, issue.ConversationID) || containsAnyText(rule.CustomerNames, issue.CustomerName) {
return 300, dispatchRuleLabel(rule, "群聊/客户")
}
productText := strings.Join([]string{issue.RoomName, issue.IssueContent, issue.AISuggestion}, "\n")
if containsAnyText(rule.ProductKeywords, productText) {
return 200, dispatchRuleLabel(rule, "产品关键词")
}
if containsAnyText(rule.IssueKeywords, strings.Join([]string{issue.IssueContent, issue.AISuggestion}, "\n")) {
return 100, dispatchRuleLabel(rule, "问题关键词")
}
return 0, ""
}
func dispatchRuleLabel(rule AfterSalesDispatchRule, kind string) string {
name := strings.TrimSpace(rule.Name)
if name == "" {
name = strings.TrimSpace(rule.ID)
}
if name == "" {
return kind
}
return kind + ": " + name
}
func containsAnyNormalized(items []string, value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
for _, item := range items {
if strings.TrimSpace(item) == value {
return true
}
}
return false
}
func containsAnyText(needles []string, haystack string) bool {
haystack = strings.ToLower(strings.TrimSpace(haystack))
if haystack == "" {
return false
}
for _, needle := range needles {
needle = strings.ToLower(strings.TrimSpace(needle))
if needle != "" && strings.Contains(haystack, needle) {
return true
}
}
return false
}
func dispatchEngineerName(userID string, fallback string, cfg AfterSalesDispatchConfig) string {
userID = strings.TrimSpace(userID)
for _, engineer := range cfg.Engineers {
if strings.TrimSpace(engineer.UserID) == userID && strings.TrimSpace(engineer.Name) != "" {
return strings.TrimSpace(engineer.Name)
}
}
return strings.TrimSpace(fallback)
}
func (e *AfterSalesIssueEngine) assignEngineer(issueID string, engineerID string) error {
cfg, err := readAfterSalesDispatchConfig()
if err != nil {
return err
}
issueID = strings.TrimSpace(issueID)
engineerID = strings.TrimSpace(engineerID)
if issueID == "" {
return fmt.Errorf("issueId为空")
}
e.mu.Lock()
defer e.mu.Unlock()
for i := range e.issues {
if e.issues[i].ID != issueID {
continue
}
e.issues[i].AssignedEngineerID = engineerID
e.issues[i].AssignedEngineerName = dispatchEngineerName(engineerID, "", cfg)
e.issues[i].DispatchRuleID = ""
if engineerID == "" {
e.issues[i].DispatchStatus = afterSalesDispatchUnassigned
e.issues[i].DispatchReason = ""
e.issues[i].DispatchConfidence = 0
e.issues[i].DispatchSource = ""
} else {
e.issues[i].DispatchStatus = afterSalesDispatchAssigned
e.issues[i].DispatchReason = "人工指定"
e.issues[i].DispatchConfidence = 1
e.issues[i].DispatchSource = dispatchSourceManual
}
e.issues[i].UpdatedAt = time.Now().Local().Format(time.RFC3339)
normalizeAfterSalesDispatchFields(&e.issues[i])
return e.saveIssuesLocked()
}
return fmt.Errorf("问题不存在")
}
func (e *AfterSalesIssueEngine) notifyEngineer(issueID string) AfterSalesNotifyResult {
cfg, err := readAfterSalesDispatchConfig()
if err != nil {
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: err.Error()}
}
issueID = strings.TrimSpace(issueID)
if issueID == "" {
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "issueId为空"}
}
var issue AfterSalesIssue
e.mu.Lock()
index := -1
for i := range e.issues {
if e.issues[i].ID == issueID {
index = i
issue = e.issues[i]
break
}
}
if index < 0 {
e.mu.Unlock()
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题不存在"}
}
normalizeAfterSalesDispatchFields(&issue)
if issue.Status != afterSalesIssueStatusPending {
e.mu.Unlock()
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题已处理或非待处理,不能通知"}
}
if strings.TrimSpace(issue.AssignedEngineerID) == "" {
e.issues[index].NotifyStatus = afterSalesNotifyFailed
e.issues[index].NotifyError = "未分配工程师"
e.issues[index].UpdatedAt = time.Now().Local().Format(time.RFC3339)
_ = e.saveIssuesLocked()
e.mu.Unlock()
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "未分配工程师"}
}
if issue.NotifyStatus == afterSalesNotifySent && issue.LastNotifiedAt > 0 && cfg.NotifyCooldownSeconds > 0 {
if time.Since(time.Unix(issue.LastNotifiedAt, 0)) < time.Duration(cfg.NotifyCooldownSeconds)*time.Second {
e.mu.Unlock()
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "已通知过,冷却时间内不重复推送"}
}
}
e.mu.Unlock()
clientID, conversationID, err := resolveEngineerConversation(issue.AssignedEngineerID)
if err == nil {
err = sendAutoReplyText(clientID, conversationID, renderAfterSalesDispatchNotification(issue, cfg))
}
e.mu.Lock()
defer e.mu.Unlock()
for i := range e.issues {
if e.issues[i].ID != issueID {
continue
}
e.issues[i].NotifyCount++
e.issues[i].UpdatedAt = time.Now().Local().Format(time.RFC3339)
if err != nil {
e.issues[i].NotifyStatus = afterSalesNotifyFailed
e.issues[i].NotifyError = err.Error()
_ = e.saveIssuesLocked()
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: err.Error()}
}
e.issues[i].NotifyStatus = afterSalesNotifySent
e.issues[i].NotifyError = ""
e.issues[i].LastNotifiedAt = time.Now().Unix()
_ = e.saveIssuesLocked()
return AfterSalesNotifyResult{IssueID: issueID, Success: true, Message: "sent"}
}
return AfterSalesNotifyResult{IssueID: issueID, Success: false, Message: "问题不存在"}
}
func (e *AfterSalesIssueEngine) batchNotifyEngineers(issueIDs []string) map[string]interface{} {
if len(uniqueNonEmptyStrings(issueIDs)) == 0 {
issueIDs = e.notifyAllAssignedPendingIDs()
}
results := make([]AfterSalesNotifyResult, 0, len(issueIDs))
successCount := 0
for _, issueID := range uniqueNonEmptyStrings(issueIDs) {
result := e.notifyEngineer(issueID)
if result.Success {
successCount++
}
results = append(results, result)
}
return map[string]interface{}{
"success": successCount == len(results),
"message": fmt.Sprintf("已成功推送 %d/%d 条", successCount, len(results)),
"data": map[string]interface{}{
"successCount": successCount,
"totalCount": len(results),
"results": results,
},
}
}
func (e *AfterSalesIssueEngine) notifyAllAssignedPendingIDs() []string {
e.mu.Lock()
defer e.mu.Unlock()
result := make([]string, 0)
for _, issue := range e.issues {
if issue.Status != afterSalesIssueStatusPending {
continue
}
if strings.TrimSpace(issue.AssignedEngineerID) == "" {
continue
}
if normalizeAfterSalesNotifyStatus(issue.NotifyStatus) != afterSalesNotifyNotSent {
continue
}
result = append(result, issue.ID)
}
return result
}
func resolveEngineerConversation(engineerID string) (uint32, string, error) {
engineerID = strings.TrimSpace(engineerID)
if engineerID == "" {
return 0, "", fmt.Errorf("工程师 userId 为空")
}
clientIDs := identityRefreshClientIDs()
if len(clientIDs) == 0 {
return 0, "", fmt.Errorf("没有活跃企微账号,无法发送通知")
}
for _, clientID := range clientIDs {
robotID := strings.TrimSpace(getClientUserID(clientID))
if robotID == "" {
continue
}
if strings.HasPrefix(engineerID, "S:") {
return clientID, engineerID, nil
}
return clientID, fmt.Sprintf("S:%s_%s", robotID, engineerID), nil
}
return 0, "", fmt.Errorf("无法推导工程师私信会话缺少当前接管账号ID")
}
func renderAfterSalesDispatchNotification(issue AfterSalesIssue, cfg AfterSalesDispatchConfig) string {
template := strings.TrimSpace(cfg.NotifyTemplate)
if template == "" {
template = defaultAfterSalesDispatchTemplate()
}
replacements := map[string]string{
"{issueId}": issue.ID,
"{roomName}": fallbackString(issue.RoomName, issue.ConversationID),
"{customerName}": normalizeAfterSalesDisplayName(issue.CustomerName),
"{issueContent}": strings.TrimSpace(issue.IssueContent),
"{aiSuggestion}": strings.TrimSpace(issue.AISuggestion),
"{createdAt}": formatDispatchIssueTime(issue.CreatedAt),
"{engineerName}": fallbackString(issue.AssignedEngineerName, issue.AssignedEngineerID),
"{engineerUserId}": issue.AssignedEngineerID,
"{imageCount}": fmt.Sprintf("%d", len(issue.ImagePaths)+len(issue.ImageRefs)),
"{dispatchReason}": issue.DispatchReason,
}
for key, value := range replacements {
template = strings.ReplaceAll(template, key, value)
}
return template
}
func defaultAfterSalesDispatchTemplate() string {
return "【售后问题待处理】\n" +
"客户/群聊:{customerName} / {roomName}\n" +
"提出时间:{createdAt}\n" +
"问题ID{issueId}\n" +
"匹配原因:{dispatchReason}\n\n" +
"问题描述:\n{issueContent}\n\n" +
"AI建议\n{aiSuggestion}\n\n" +
"相关图片:{imageCount} 张"
}
func formatDispatchIssueTime(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t.Local().Format("2006-01-02 15:04")
}
return value
}

View File

@@ -0,0 +1,410 @@
package main
import (
"os"
"strings"
"testing"
"time"
"qiweimanager/config"
)
func TestAfterSalesDispatchRulePriority(t *testing.T) {
issue := AfterSalesIssue{
ConversationID: "R:vip-room",
RoomName: "热成像售后群",
CustomerName: "华南客户",
IssueContent: "设备启动报错",
AISuggestion: "建议检查线缆",
}
cfg := AfterSalesDispatchConfig{
Rules: []AfterSalesDispatchRule{
{ID: "issue", Name: "报错", EngineerUserID: "engineer-issue", IssueKeywords: []string{"报错"}, Enabled: true},
{ID: "product", Name: "热成像", EngineerUserID: "engineer-product", ProductKeywords: []string{"热成像"}, Enabled: true},
{ID: "room", Name: "VIP群", EngineerUserID: "engineer-room", ConversationIDs: []string{"R:vip-room"}, Enabled: true},
},
}
match, ok := matchAfterSalesDispatchRule(issue, cfg)
if !ok {
t.Fatal("expected a dispatch match")
}
if match.Rule.EngineerUserID != "engineer-room" {
t.Fatalf("expected conversation/customer rule to win, got %#v", match)
}
}
func TestAfterSalesDispatchNoMatchStaysUnassigned(t *testing.T) {
issue := AfterSalesIssue{IssueContent: "普通咨询"}
cfg := AfterSalesDispatchConfig{
Rules: []AfterSalesDispatchRule{{ID: "r1", EngineerUserID: "engineer", ProductKeywords: []string{"热成像"}, Enabled: true}},
}
if match, ok := matchAfterSalesDispatchRule(issue, cfg); ok {
t.Fatalf("expected no match, got %#v", match)
}
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "普通咨询"}}}
engine.applyLegacyDispatchSuggestionsLocked(cfg)
if engine.issues[0].DispatchStatus != afterSalesDispatchUnassigned {
t.Fatalf("expected unassigned, got %s", engine.issues[0].DispatchStatus)
}
}
func TestAfterSalesAIDispatchHighConfidenceAssignsEngineer(t *testing.T) {
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
Status: afterSalesIssueStatusPending,
IssueContent: "热成像镜头无法调焦",
NotifyStatus: afterSalesNotifyNotSent,
}}}
cfg := AfterSalesDispatchConfig{
AutoNotifyMinConfidence: 0.75,
Engineers: []AfterSalesEngineer{{
UserID: "engineer-a",
Name: "张工",
Description: "负责热成像镜头调焦问题",
Enabled: true,
}},
}
normalizeAfterSalesDispatchConfig(&cfg)
engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{
EngineerUserID: "engineer-a",
Reason: "热成像镜头调焦属于张工职责",
Confidence: 0.92,
}, nil)
got := engine.issues[0]
if got.AssignedEngineerID != "engineer-a" || got.DispatchStatus != afterSalesDispatchSuggested || got.DispatchSource != dispatchSourceAI {
t.Fatalf("expected AI suggested engineer, got %#v", got)
}
if got.DispatchConfidence != 0.92 {
t.Fatalf("expected confidence 0.92, got %v", got.DispatchConfidence)
}
}
func TestAfterSalesAIDispatchLowConfidenceStaysUnassigned(t *testing.T) {
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
Status: afterSalesIssueStatusPending,
IssueContent: "客户说有点奇怪",
NotifyStatus: afterSalesNotifyNotSent,
}}}
cfg := AfterSalesDispatchConfig{
AutoNotifyMinConfidence: 0.75,
Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头", Enabled: true}},
}
normalizeAfterSalesDispatchConfig(&cfg)
engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{
EngineerUserID: "engineer-a",
Reason: "不够确定",
Confidence: 0.45,
}, nil)
got := engine.issues[0]
if got.AssignedEngineerID != "" || got.DispatchStatus != afterSalesDispatchUnassigned {
t.Fatalf("expected unassigned low-confidence issue, got %#v", got)
}
if got.DispatchConfidence != 0.45 {
t.Fatalf("expected stored low confidence, got %v", got.DispatchConfidence)
}
}
func TestAfterSalesManualAssignmentNotOverwrittenByAI(t *testing.T) {
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
Status: afterSalesIssueStatusPending,
AssignedEngineerID: "manual-engineer",
DispatchStatus: afterSalesDispatchAssigned,
DispatchSource: dispatchSourceManual,
NotifyStatus: afterSalesNotifyNotSent,
}}}
cfg := AfterSalesDispatchConfig{
AutoNotifyMinConfidence: 0.75,
Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头", Enabled: true}},
}
normalizeAfterSalesDispatchConfig(&cfg)
engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Confidence: 0.99}, nil)
if engine.issues[0].AssignedEngineerID != "manual-engineer" || engine.issues[0].DispatchSource != dispatchSourceManual {
t.Fatalf("manual assignment was overwritten: %#v", engine.issues[0])
}
}
func TestAfterSalesAutoNotifyEnabledSendsHighConfidenceRecommendation(t *testing.T) {
cleanupDispatchConfig(t)
oldMatcher := afterSalesDispatchMatcher
oldConfigSource := afterSalesDispatchAIConfigSource
oldSender := sendAutoReplyTextSender
afterSalesDispatchMatcher = func(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) {
return afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Reason: "职责匹配", Confidence: 0.91}, nil
}
afterSalesDispatchAIConfigSource = func() (config.AIConfig, error) {
return config.AIConfig{BaseURL: "http://127.0.0.1", Model: "test"}, nil
}
sent := 0
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
sent++
return nil
}
t.Cleanup(func() {
afterSalesDispatchMatcher = oldMatcher
afterSalesDispatchAIConfigSource = oldConfigSource
sendAutoReplyTextSender = oldSender
})
clientIdMutex.Lock()
oldGlobalClientID := globalClientId
oldGlobalClientMap := globalClientMap
globalClientId = 7
globalClientMap = map[uint32]string{7: "robot-user"}
clientIdMutex.Unlock()
t.Cleanup(func() {
clientIdMutex.Lock()
globalClientId = oldGlobalClientID
globalClientMap = oldGlobalClientMap
clientIdMutex.Unlock()
})
cfg := AfterSalesDispatchConfig{
AutoNotifyEnabled: true,
AutoNotifyMinConfidence: 0.75,
NotifyCooldownSeconds: 1,
Engineers: []AfterSalesEngineer{{
UserID: "engineer-a",
Name: "张工",
Description: "负责镜头问题",
Enabled: true,
}},
}
normalizeAfterSalesDispatchConfig(&cfg)
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
Status: afterSalesIssueStatusPending,
IssueContent: "镜头无法调焦",
NotifyStatus: afterSalesNotifyNotSent,
}}}
engine.refreshDispatchAssignments(cfg)
if sent != 1 {
t.Fatalf("expected one automatic notification, got %d", sent)
}
if engine.issues[0].NotifyStatus != afterSalesNotifySent {
t.Fatalf("expected sent issue, got %#v", engine.issues[0])
}
}
func TestAfterSalesAutoNotifyDisabledDoesNotSend(t *testing.T) {
oldMatcher := afterSalesDispatchMatcher
oldConfigSource := afterSalesDispatchAIConfigSource
oldSender := sendAutoReplyTextSender
afterSalesDispatchMatcher = func(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) {
return afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Reason: "职责匹配", Confidence: 0.91}, nil
}
afterSalesDispatchAIConfigSource = func() (config.AIConfig, error) {
return config.AIConfig{BaseURL: "http://127.0.0.1", Model: "test"}, nil
}
sent := 0
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
sent++
return nil
}
t.Cleanup(func() {
afterSalesDispatchMatcher = oldMatcher
afterSalesDispatchAIConfigSource = oldConfigSource
sendAutoReplyTextSender = oldSender
})
cfg := AfterSalesDispatchConfig{
AutoNotifyEnabled: false,
AutoNotifyMinConfidence: 0.75,
Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头问题", Enabled: true}},
}
normalizeAfterSalesDispatchConfig(&cfg)
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "镜头无法调焦", NotifyStatus: afterSalesNotifyNotSent}}}
engine.refreshDispatchAssignments(cfg)
if sent != 0 {
t.Fatalf("expected no automatic notification when disabled, got %d", sent)
}
if engine.issues[0].AssignedEngineerID != "engineer-a" || engine.issues[0].NotifyStatus != afterSalesNotifyNotSent {
t.Fatalf("expected recommendation without notification, got %#v", engine.issues[0])
}
}
func TestAfterSalesNotifyCooldownSkipsDuplicateSend(t *testing.T) {
cleanupDispatchConfig(t)
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{
NotifyCooldownSeconds: 600,
}); err != nil {
t.Fatalf("save config: %v", err)
}
sent := 0
oldSender := sendAutoReplyTextSender
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
sent++
return nil
}
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
Status: afterSalesIssueStatusPending,
AssignedEngineerID: "engineer-a",
DispatchStatus: afterSalesDispatchAssigned,
NotifyStatus: afterSalesNotifySent,
LastNotifiedAt: time.Now().Unix(),
}}}
result := engine.notifyEngineer("i1")
if result.Success || !strings.Contains(result.Message, "冷却") {
t.Fatalf("expected cooldown failure, got %#v", result)
}
if sent != 0 {
t.Fatalf("expected no send during cooldown, got %d", sent)
}
}
func TestAfterSalesNotifyResolvedIssueDoesNotSend(t *testing.T) {
cleanupDispatchConfig(t)
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{
NotifyCooldownSeconds: 1,
}); err != nil {
t.Fatalf("save config: %v", err)
}
sent := 0
oldSender := sendAutoReplyTextSender
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
sent++
return nil
}
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
Status: afterSalesIssueStatusResolved,
AssignedEngineerID: "engineer-a",
DispatchStatus: afterSalesDispatchAssigned,
NotifyStatus: afterSalesNotifyNotSent,
}}}
result := engine.notifyEngineer("i1")
if result.Success || !strings.Contains(result.Message, "非待处理") {
t.Fatalf("expected non-pending failure, got %#v", result)
}
if sent != 0 {
t.Fatalf("expected no send for resolved issue, got %d", sent)
}
if engine.issues[0].NotifyStatus != afterSalesNotifyNotSent {
t.Fatalf("expected notify status unchanged, got %#v", engine.issues[0])
}
}
func TestAfterSalesBatchNotifyPartialFailure(t *testing.T) {
cleanupDispatchConfig(t)
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{
NotifyCooldownSeconds: 1,
}); err != nil {
t.Fatalf("save config: %v", err)
}
oldSender := sendAutoReplyTextSender
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
if clientID != 7 || conversationID != "S:robot-user_engineer-a" {
t.Fatalf("unexpected send target client=%d conversation=%s", clientID, conversationID)
}
if !strings.Contains(content, "售后问题待处理") || !strings.Contains(content, "i1") {
t.Fatalf("unexpected notification content: %s", content)
}
return nil
}
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
clientIdMutex.Lock()
oldGlobalClientID := globalClientId
oldGlobalClientMap := globalClientMap
globalClientId = 7
globalClientMap = map[uint32]string{7: "robot-user"}
clientIdMutex.Unlock()
t.Cleanup(func() {
clientIdMutex.Lock()
globalClientId = oldGlobalClientID
globalClientMap = oldGlobalClientMap
clientIdMutex.Unlock()
})
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{
{
ID: "i1",
CreatedAt: time.Now().Format(time.RFC3339),
Status: afterSalesIssueStatusPending,
IssueContent: "设备报错",
AssignedEngineerID: "engineer-a",
AssignedEngineerName: "张工",
DispatchStatus: afterSalesDispatchAssigned,
NotifyStatus: afterSalesNotifyNotSent,
},
{
ID: "i2",
Status: afterSalesIssueStatusPending,
IssueContent: "未分配问题",
NotifyStatus: afterSalesNotifyNotSent,
},
}}
result := engine.batchNotifyEngineers([]string{"i1", "i2"})
if result["success"].(bool) {
t.Fatalf("expected partial failure, got %#v", result)
}
data := result["data"].(map[string]interface{})
if data["successCount"].(int) != 1 || data["totalCount"].(int) != 2 {
t.Fatalf("unexpected batch data: %#v", data)
}
if engine.issues[0].NotifyStatus != afterSalesNotifySent {
t.Fatalf("expected first issue sent, got %#v", engine.issues[0])
}
if engine.issues[1].NotifyStatus != afterSalesNotifyFailed {
t.Fatalf("expected second issue failed, got %#v", engine.issues[1])
}
}
func TestAfterSalesBatchNotifyEmptyMeansAllAssignedUnsent(t *testing.T) {
cleanupDispatchConfig(t)
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{NotifyCooldownSeconds: 1}); err != nil {
t.Fatalf("save config: %v", err)
}
sent := make([]string, 0)
oldSender := sendAutoReplyTextSender
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
sent = append(sent, content)
return nil
}
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
clientIdMutex.Lock()
oldGlobalClientID := globalClientId
oldGlobalClientMap := globalClientMap
globalClientId = 7
globalClientMap = map[uint32]string{7: "robot-user"}
clientIdMutex.Unlock()
t.Cleanup(func() {
clientIdMutex.Lock()
globalClientId = oldGlobalClientID
globalClientMap = oldGlobalClientMap
clientIdMutex.Unlock()
})
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{
{ID: "send-me", Status: afterSalesIssueStatusPending, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifyNotSent},
{ID: "skip-unassigned", Status: afterSalesIssueStatusPending, NotifyStatus: afterSalesNotifyNotSent},
{ID: "skip-sent", Status: afterSalesIssueStatusPending, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifySent},
{ID: "skip-resolved", Status: afterSalesIssueStatusResolved, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifyNotSent},
}}
result := engine.batchNotifyEngineers(nil)
if !result["success"].(bool) {
t.Fatalf("expected all eligible notifications to succeed, got %#v", result)
}
data := result["data"].(map[string]interface{})
if data["successCount"].(int) != 1 || data["totalCount"].(int) != 1 {
t.Fatalf("unexpected all-notify data: %#v", data)
}
if len(sent) != 1 || !strings.Contains(sent[0], "send-me") {
t.Fatalf("expected only one notification for send-me, got %#v", sent)
}
}
func cleanupDispatchConfig(t *testing.T) {
t.Helper()
path := afterSalesDispatchConfigPath()
t.Cleanup(func() {
_ = os.Remove(path)
})
_ = os.Remove(path)
}

View File

@@ -0,0 +1,585 @@
package main
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
var afterSalesAICollector = callAfterSalesAI
func observeAfterSalesEvent(clientID int32, raw map[string]interface{}) {
getAfterSalesIssueEngine().observeEvent(clientID, raw)
}
func (e *AfterSalesIssueEngine) observeEvent(clientID int32, raw map[string]interface{}) {
message, ok := e.extractMessage(clientID, raw)
if !ok {
return
}
e.mu.Lock()
defer e.mu.Unlock()
for i := range e.messages {
if e.messages[i].MessageID == message.MessageID {
e.messages[i] = message
e.trimMessagesLocked(time.Now())
e.updateStateMessageCountLocked()
_ = e.saveMessagesLocked()
_ = e.saveStateLocked()
return
}
}
e.messages = append(e.messages, message)
e.trimMessagesLocked(time.Now())
e.updateStateMessageCountLocked()
if err := e.saveMessagesLocked(); err != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞垮劗濡插牓鏌涢銈呮瀺缂佽鲸鐗滅槐鎾诲磼濞戞瑥纰嶉梺瀹︽澘濡界€垫澘瀚蹇涱敃閵? %v", err)
}
if err := e.saveStateLocked(); err != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", err)
}
}
func (e *AfterSalesIssueEngine) extractMessage(clientID int32, raw map[string]interface{}) (AfterSalesMessage, bool) {
autoEngine := getAutoReplyEngine()
autoEngine.observeGroupNames(clientID, raw)
msg := extractAutoReplyMessage(clientID, raw)
autoEngine.enrichAutoReplyMessage(&msg, time.Now())
if !msg.IsGroup || strings.TrimSpace(msg.ConversationID) == "" {
return AfterSalesMessage{}, false
}
identity := autoEngine.classifySenderIdentity(msg)
imagePath, imageRef := extractAfterSalesImageFromMessage(msg, raw)
messageID := strings.TrimSpace(firstNonEmpty(msg.ServerID, msg.LocalID))
if messageID == "" {
messageID = stableAfterSalesMessageID(clientID, msg, imagePath, imageRef)
}
fileAttachment := extractAfterSalesFileFromMessage(msg, raw, messageID)
messageType := msg.MessageType
if imagePath != "" || imageRef != "" || looksLikeImageMessage(raw) {
messageType = "image"
}
if fileAttachment.Path != "" || fileAttachment.Ref != "" || looksLikeAfterSalesFileMessage(msg, raw) {
messageType = "file"
}
if messageType != "text" && messageType != "image" && messageType != "file" {
return AfterSalesMessage{}, false
}
content := strings.TrimSpace(msg.Content)
if content == "" && messageType == "image" {
content = "image"
}
if content == "" && messageType == "file" {
content = "file"
if fileAttachment.Name != "" {
content += ": " + fileAttachment.Name
}
}
if content == "" && imagePath == "" && imageRef == "" && fileAttachment.Path == "" && fileAttachment.Ref == "" {
return AfterSalesMessage{}, false
}
sendAt := parseAfterSalesSendTime(msg.SendTime)
if sendAt <= 0 {
sendAt = time.Now().Unix()
}
senderName := strings.TrimSpace(firstNonEmpty(identity.Name, msg.FromNickName))
if senderName == "" {
senderName = "unknown customer"
}
roomName := strings.TrimSpace(msg.GroupName)
if roomName == "" || roomName == msg.ConversationID {
if resolved := autoEngine.ResolveGroupName(msg.ConversationID); resolved != "" {
roomName = resolved
}
}
if roomName == "" {
roomName = msg.ConversationID
}
return AfterSalesMessage{
MessageID: messageID,
ClientID: clientID,
ConversationID: msg.ConversationID,
RoomName: roomName,
SenderUserID: msg.FromWxID,
SenderName: senderName,
SenderIdentity: identity.Kind,
Content: content,
MessageType: messageType,
ImagePath: imagePath,
ImageRef: imageRef,
FilePath: fileAttachment.Path,
FileRef: fileAttachment.Ref,
FileName: fileAttachment.Name,
FileContent: fileAttachment.Content,
FileExtractStatus: fileAttachment.ExtractStatus,
SendTime: sendAt,
ReceivedAt: time.Now().Unix(),
}, true
}
func (e *AfterSalesIssueEngine) triggerCollectAsync(conversationID string, manual bool) (bool, string) {
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
if manual {
e.mu.Lock()
messages := append([]AfterSalesMessage(nil), e.messages...)
e.mu.Unlock()
if len(filterAfterSalesMessages(messages, 0, time.Now().Unix(), conversationID)) == 0 {
return true, afterSalesCollectEmptyMessage(conversationID, manual, 0)
}
}
e.mu.Lock()
if e.state.Collecting {
e.mu.Unlock()
return false, "after-sales collection is already running"
}
e.state.Collecting = true
e.state.LastError = ""
e.updateStateMessageCountLocked()
_ = e.saveStateLocked()
e.mu.Unlock()
go e.collectLockedAsync(conversationID, manual)
if manual && conversationID != "" {
if name := getAutoReplyEngine().ResolveGroupName(conversationID); name != "" {
return true, fmt.Sprintf("started after-sales collection for group %s", name)
}
return true, "started after-sales collection for selected group"
}
return true, "started after-sales collection"
}
func (e *AfterSalesIssueEngine) collectLockedAsync(conversationID string, manual bool) {
prewarmAfterSalesGroupNames()
added, scanned, err := e.collectNow(conversationID, manual)
e.mu.Lock()
defer e.mu.Unlock()
e.state.Collecting = false
e.state.LastAddedCount = added
e.state.LastCollectedAt = time.Now().Unix()
if err != nil {
e.state.LastError = err.Error()
} else {
e.state.LastError = afterSalesCollectEmptyMessage(conversationID, manual, scanned)
if !manual {
e.state.LastCollectAt = time.Now().Unix()
}
e.repairIssuesLocked()
}
e.updateStateMessageCountLocked()
if saveErr := e.saveStateLocked(); saveErr != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", saveErr)
}
}
func (e *AfterSalesIssueEngine) collectNow(conversationID string, manual bool) (int, int, error) {
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
e.mu.Lock()
state := e.state
messages := append([]AfterSalesMessage(nil), e.messages...)
issues := append([]AfterSalesIssue(nil), e.issues...)
e.mu.Unlock()
now := time.Now()
from := int64(0)
if !manual {
from = state.LastCollectAt
if from <= 0 {
from = now.Add(-afterSalesFirstCollectWindow).Unix()
}
}
targets := filterAfterSalesMessages(messages, from, now.Unix(), conversationID)
if len(targets) == 0 {
return 0, 0, nil
}
cfg := getAutoReplyEngine().getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return 0, len(targets), fmt.Errorf("AI config is incomplete, cannot collect after-sales issues")
}
existingFingerprints := make(map[string]AfterSalesIssue)
for _, issue := range issues {
if issue.Fingerprint != "" {
existingFingerprints[issue.Fingerprint] = issue
}
}
added := 0
for _, batch := range batchAfterSalesMessages(targets, afterSalesBatchSize) {
candidates, err := afterSalesAICollector(cfg.AI, batch)
if err != nil {
return added, len(targets), err
}
added += e.mergeAIIssueCandidates(candidates, batch, existingFingerprints)
}
return added, len(targets), nil
}
func prewarmAfterSalesGroupNames() {
engine := getAutoReplyEngine()
done := make(chan struct{})
go func() {
_ = engine.refreshIdentityGroups("after_sales_collect")
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
func (e *AfterSalesIssueEngine) mergeAIIssueCandidates(candidates []afterSalesAIIssueCandidate, batch []AfterSalesMessage, existing map[string]AfterSalesIssue) int {
if len(candidates) == 0 {
return 0
}
messageByID := make(map[string]AfterSalesMessage)
for _, msg := range batch {
messageByID[msg.MessageID] = msg
}
batchID := newAfterSalesID()
now := time.Now().Local().Format(time.RFC3339)
added := 0
e.mu.Lock()
for _, candidate := range candidates {
issueContent := strings.TrimSpace(candidate.IssueContent)
if issueContent == "" {
continue
}
sourceIDs := uniqueNonEmptyStrings(candidate.SourceMessageIDs)
seed := firstCandidateMessage(sourceIDs, batch, messageByID)
customerUserID := strings.TrimSpace(candidate.CustomerUserID)
if customerUserID == "" {
customerUserID = seed.SenderUserID
}
customerName := normalizeAfterSalesDisplayName(firstNonEmpty(candidate.CustomerName, seed.SenderName))
roomName := strings.TrimSpace(firstNonEmpty(candidate.RoomName, seed.RoomName))
conversationID := seed.ConversationID
if conversationID == "" && len(batch) > 0 {
conversationID = batch[0].ConversationID
}
if roomName == "" || roomName == conversationID || strings.HasPrefix(roomName, "R:") {
if resolved := getAutoReplyEngine().ResolveGroupName(conversationID); resolved != "" {
roomName = resolved
}
}
if roomName == "" {
roomName = conversationID
}
sourceClientID := seed.ClientID
sourceAccountUserID, sourceAccountName := getAutoReplyEngine().sourceAccountForClient(sourceClientID)
fingerprint := afterSalesFingerprint(conversationID, customerUserID, issueContent)
if existingIssue, ok := existing[fingerprint]; ok {
if existingIssue.Status == afterSalesIssueStatusResolved || existingIssue.Status == afterSalesIssueStatusIgnored || existingIssue.ID != "" {
continue
}
}
imagePaths, imageRefs := collectCandidateImages(candidate, sourceIDs, batch, messageByID)
fileAttachments := collectCandidateFileAttachments(sourceIDs, batch, messageByID)
issue := AfterSalesIssue{
ID: newAfterSalesID(),
CreatedAt: now,
UpdatedAt: now,
ConversationID: conversationID,
RoomName: roomName,
SourceClientID: sourceClientID,
SourceAccountUserID: sourceAccountUserID,
SourceAccountName: sourceAccountName,
CustomerUserID: customerUserID,
CustomerName: customerName,
IssueContent: issueContent,
ImagePaths: imagePaths,
ImageRefs: imageRefs,
FileAttachments: fileAttachments,
AISuggestion: strings.TrimSpace(candidate.AISuggestion),
Status: afterSalesIssueStatusPending,
SourceMessageIDs: sourceIDs,
Fingerprint: fingerprint,
CollectBatchID: batchID,
AIConfidence: candidate.Confidence,
AISuggestionEdited: false,
}
if len(issue.SourceMessageIDs) == 0 {
issue.SourceMessageIDs = messageIDsForBatch(batch)
}
e.issues = append(e.issues, issue)
existing[fingerprint] = issue
added++
}
if added > 0 {
if err := e.saveIssuesLocked(); err != nil && globalLogger != nil {
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err)
}
}
shouldRefreshDispatch := added > 0
e.mu.Unlock()
if shouldRefreshDispatch {
e.refreshDispatchAssignmentsAsync()
}
return added
}
func (e *AfterSalesIssueEngine) autoCollectLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
e.mu.Lock()
enabled := e.state.AutoCollectEnabled
collecting := e.state.Collecting
last := e.state.LastCollectedAt
e.mu.Unlock()
if !enabled || collecting {
continue
}
if last > 0 && time.Since(time.Unix(last, 0)) < afterSalesAutoCollectEvery {
continue
}
e.triggerCollectAsync("", false)
}
}
func (e *AfterSalesIssueEngine) trimMessagesLocked(now time.Time) {
cutoff := now.Add(-afterSalesMessageBufferHours * time.Hour).Unix()
next := e.messages[:0]
for _, msg := range e.messages {
ts := msg.SendTime
if ts <= 0 {
ts = msg.ReceivedAt
}
if ts >= cutoff {
next = append(next, msg)
}
}
e.messages = next
}
func filterAfterSalesMessages(messages []AfterSalesMessage, from int64, to int64, conversationID string) []AfterSalesMessage {
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
result := make([]AfterSalesMessage, 0, len(messages))
for _, msg := range messages {
if conversationID != "" && strings.TrimSpace(msg.ConversationID) != conversationID {
continue
}
ts := msg.SendTime
if ts <= 0 {
ts = msg.ReceivedAt
}
if ts > from && ts <= to {
result = append(result, msg)
}
}
sort.Slice(result, func(i, j int) bool {
if result[i].ConversationID != result[j].ConversationID {
return result[i].ConversationID < result[j].ConversationID
}
return result[i].SendTime < result[j].SendTime
})
return result
}
func normalizeAfterSalesCollectConversationID(conversationID string) string {
conversationID = strings.TrimSpace(conversationID)
if strings.EqualFold(conversationID, afterSalesManualCollectAll) {
return ""
}
return conversationID
}
func afterSalesCollectEmptyMessage(conversationID string, manual bool, scanned int) string {
if !manual || scanned > 0 {
return ""
}
if normalizeAfterSalesCollectConversationID(conversationID) != "" {
return "selected group has no cached messages to analyze"
}
return "no cached messages to analyze"
}
func batchAfterSalesMessages(messages []AfterSalesMessage, size int) [][]AfterSalesMessage {
if size <= 0 {
size = afterSalesBatchSize
}
grouped := make(map[string][]AfterSalesMessage)
keys := make([]string, 0)
for _, msg := range messages {
key := msg.ConversationID
if _, exists := grouped[key]; !exists {
keys = append(keys, key)
}
grouped[key] = append(grouped[key], msg)
}
sort.Strings(keys)
var batches [][]AfterSalesMessage
for _, key := range keys {
items := grouped[key]
sort.Slice(items, func(i, j int) bool { return items[i].SendTime < items[j].SendTime })
for start := 0; start < len(items); start += size {
end := start + size
if end > len(items) {
end = len(items)
}
batches = append(batches, append([]AfterSalesMessage(nil), items[start:end]...))
}
}
return batches
}
func firstCandidateMessage(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) AfterSalesMessage {
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok && msg.SenderIdentity != senderIdentityInternal {
return msg
}
}
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok {
return msg
}
}
for _, msg := range batch {
if msg.SenderIdentity != senderIdentityInternal {
return msg
}
}
if len(batch) > 0 {
return batch[0]
}
return AfterSalesMessage{}
}
func collectCandidateImages(candidate afterSalesAIIssueCandidate, sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) ([]string, []string) {
paths := append([]string(nil), candidate.ImagePaths...)
refs := append([]string(nil), candidate.ImageRefs...)
addMessageImage := func(msg AfterSalesMessage) {
if msg.ImagePath != "" {
paths = append(paths, msg.ImagePath)
}
if msg.ImageRef != "" {
refs = append(refs, msg.ImageRef)
}
}
if len(sourceIDs) > 0 {
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok {
addMessageImage(msg)
}
}
} else {
for _, msg := range batch {
addMessageImage(msg)
}
}
return uniqueExistingImagePaths(paths), uniqueNonEmptyStrings(refs)
}
func collectCandidateFileAttachments(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) []AfterSalesFileAttachment {
files := make([]AfterSalesFileAttachment, 0)
addMessageFile := func(msg AfterSalesMessage) {
file := AfterSalesFileAttachment{
Name: msg.FileName,
Path: msg.FilePath,
Ref: msg.FileRef,
Content: msg.FileContent,
ExtractStatus: msg.FileExtractStatus,
SourceMessageID: msg.MessageID,
}
file = normalizeAfterSalesFileAttachment(file)
if file.Path != "" || file.Ref != "" || file.Content != "" {
files = append(files, file)
}
}
if len(sourceIDs) > 0 {
for _, id := range sourceIDs {
if msg, ok := byID[id]; ok {
addMessageFile(msg)
}
}
} else {
for _, msg := range batch {
addMessageFile(msg)
}
}
return normalizeAfterSalesFileAttachments(files)
}
func messageIDsForBatch(batch []AfterSalesMessage) []string {
result := make([]string, 0, len(batch))
for _, msg := range batch {
result = append(result, msg.MessageID)
}
return uniqueNonEmptyStrings(result)
}
func uniqueExistingImagePaths(items []string) []string {
seen := make(map[string]struct{})
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, exists := seen[item]; exists {
continue
}
if _, err := os.Stat(item); err == nil {
seen[item] = struct{}{}
result = append(result, item)
}
}
return result
}
func afterSalesFingerprint(conversationID string, customerUserID string, content string) string {
normalized := normalizeAfterSalesIssueText(content)
raw := strings.Join([]string{strings.TrimSpace(conversationID), strings.TrimSpace(customerUserID), normalized}, "|")
sum := sha1.Sum([]byte(raw))
return hex.EncodeToString(sum[:])
}
func normalizeAfterSalesIssueText(text string) string {
text = strings.ToLower(strings.TrimSpace(text))
replacer := strings.NewReplacer("\r", "", "\n", "", "\t", "", " ", "", "", ",", "。", ".", "", "?", "", "!")
return replacer.Replace(text)
}
func stableAfterSalesMessageID(clientID int32, msg autoReplyMessage, imagePath string, imageRef string) string {
raw := fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s|%s", clientID, msg.ConversationID, msg.SendTime, msg.FromWxID, msg.Content, imagePath, imageRef, msg.MediaFileName, msg.MediaFileID)
sum := sha1.Sum([]byte(raw))
return hex.EncodeToString(sum[:])
}
func newAfterSalesID() string {
return uuid.NewString()
}
func parseAfterSalesSendTime(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
var n int64
if err := json.Unmarshal([]byte(raw), &n); err == nil {
if n > 1000000000000 {
n = n / 1000
}
return n
}
if _, err := fmt.Sscanf(raw, "%d", &n); err == nil {
if n > 1000000000000 {
n = n / 1000
}
return n
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

222
helper/after_sales_file.go Normal file
View File

@@ -0,0 +1,222 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const (
afterSalesFileContentLimit = 6000
afterSalesFilePromptLimit = 1800
afterSalesFileStatusReady = "parsed"
afterSalesFileStatusSaved = "saved"
afterSalesFileStatusUnsupported = "unsupported"
afterSalesFileStatusMissing = "missing"
afterSalesFileStatusDownloadFail = "download_failed"
afterSalesFileStatusExtractFailed = "extract_failed"
)
func extractAfterSalesFileFromMessage(msg autoReplyMessage, raw map[string]interface{}, sourceMessageID string) AfterSalesFileAttachment {
if !looksLikeAfterSalesFileMessage(msg, raw) {
return AfterSalesFileAttachment{}
}
attachment := AfterSalesFileAttachment{
Name: strings.TrimSpace(msg.MediaFileName),
Ref: firstNonEmpty(strings.TrimSpace(msg.MediaURL), strings.TrimSpace(msg.MediaFileID)),
SourceMessageID: strings.TrimSpace(sourceMessageID),
}
for _, value := range []string{msg.MediaLocalPath, firstLocalMediaPathFromValue(raw)} {
if path := normalizedExistingFilePath(value); path != "" {
attachment.Path = path
break
}
}
if attachment.Path == "" {
if path, err := ensureAutoReplyMediaLocalPath(msg); err == nil {
attachment.Path = normalizedExistingFilePath(path)
} else {
attachment.ExtractStatus = afterSalesFileStatusDownloadFail
}
}
if attachment.Name == "" {
attachment.Name = firstNonEmpty(filepath.Base(attachment.Path), filepath.Base(strings.TrimSpace(msg.MediaURL)), strings.TrimSpace(msg.MediaFileID), "客户附件")
}
if attachment.Path == "" {
if attachment.ExtractStatus == "" {
attachment.ExtractStatus = afterSalesFileStatusDownloadFail
}
return attachment
}
content, status := extractAfterSalesFileContent(attachment.Path)
attachment.Content = content
attachment.ExtractStatus = status
return normalizeAfterSalesFileAttachment(attachment)
}
func looksLikeAfterSalesFileMessage(msg autoReplyMessage, raw map[string]interface{}) bool {
if strings.TrimSpace(msg.MediaKind) == "file" || msg.RawType == 11045 {
return true
}
if raw != nil {
if typeVal, ok := raw["type"]; ok && intFromAny(typeVal) == 11045 {
return true
}
if event := strings.TrimSpace(stringFromAny(raw["event"])); event == "20005" {
return true
}
}
return false
}
func normalizedExistingFilePath(value string) string {
value = strings.Trim(strings.TrimSpace(value), "\"'")
if value == "" {
return ""
}
if filepath.IsAbs(value) {
if _, err := os.Stat(value); err == nil {
return value
}
return ""
}
candidate := resolveAutoReplyPath(value)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
return ""
}
func extractAfterSalesFileContent(path string) (string, string) {
if strings.TrimSpace(path) == "" {
return "", afterSalesFileStatusMissing
}
info, err := os.Stat(path)
if err != nil {
return "", afterSalesFileStatusMissing
}
if info.IsDir() {
return "", afterSalesFileStatusUnsupported
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".zip" {
return "", "zip_not_parsed"
}
switch ext {
case ".txt", ".md", ".csv", ".xlsx", ".docx", ".pdf":
default:
return "", afterSalesFileStatusUnsupported
}
blocks, err := parseKnowledgeFile(path, filepath.Dir(path))
if err != nil {
var warning knowledgeParseWarning
if !errorAs(err, &warning) {
return "", afterSalesFileStatusExtractFailed + ": " + truncateText(err.Error(), 160)
}
}
parts := make([]string, 0, len(blocks))
for _, block := range blocks {
text := strings.TrimSpace(block.Content)
if text == "" {
continue
}
if strings.TrimSpace(block.Title) != "" {
text = strings.TrimSpace(block.Title) + "\n" + text
}
parts = append(parts, text)
}
content := truncateText(strings.TrimSpace(strings.Join(parts, "\n\n")), afterSalesFileContentLimit)
if content == "" {
return "", afterSalesFileStatusSaved
}
return content, afterSalesFileStatusReady
}
func normalizeAfterSalesFileAttachment(item AfterSalesFileAttachment) AfterSalesFileAttachment {
item.Name = strings.TrimSpace(item.Name)
item.Path = strings.Trim(strings.TrimSpace(item.Path), "\"'")
item.Ref = strings.TrimSpace(item.Ref)
item.Content = truncateText(strings.TrimSpace(item.Content), afterSalesFileContentLimit)
item.ExtractStatus = strings.TrimSpace(item.ExtractStatus)
item.SourceMessageID = strings.TrimSpace(item.SourceMessageID)
if item.Name == "" {
item.Name = firstNonEmpty(filepath.Base(item.Path), filepath.Base(item.Ref), "客户附件")
}
if item.ExtractStatus == "" {
if item.Content != "" {
item.ExtractStatus = afterSalesFileStatusReady
} else if item.Path != "" {
item.ExtractStatus = afterSalesFileStatusSaved
} else {
item.ExtractStatus = afterSalesFileStatusDownloadFail
}
}
return item
}
func normalizeAfterSalesFileAttachments(items []AfterSalesFileAttachment) []AfterSalesFileAttachment {
seen := make(map[string]struct{})
result := make([]AfterSalesFileAttachment, 0, len(items))
for _, item := range items {
item = normalizeAfterSalesFileAttachment(item)
if item.Path == "" && item.Ref == "" && item.Name == "" && item.Content == "" {
continue
}
key := firstNonEmpty(item.SourceMessageID, item.Path, item.Ref, item.Name+"|"+item.Content)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func afterSalesFilePromptText(files []AfterSalesFileAttachment, limit int) string {
if limit <= 0 {
limit = afterSalesFilePromptLimit
}
var b strings.Builder
for _, file := range normalizeAfterSalesFileAttachments(files) {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString("文件:")
b.WriteString(firstNonEmpty(file.Name, filepath.Base(file.Path), file.Ref))
if file.ExtractStatus != "" {
b.WriteString(" 状态:")
b.WriteString(file.ExtractStatus)
}
if file.Content != "" {
b.WriteString("\n内容:\n")
b.WriteString(truncateText(file.Content, limit))
}
}
return strings.TrimSpace(b.String())
}
func formatAfterSalesFileAttachmentsForExcel(files []AfterSalesFileAttachment, includeContent bool) string {
files = normalizeAfterSalesFileAttachments(files)
if len(files) == 0 {
return ""
}
lines := make([]string, 0, len(files))
for _, file := range files {
line := firstNonEmpty(file.Name, filepath.Base(file.Path), file.Ref, "客户附件")
if file.Path != "" {
line += " | " + file.Path
} else if file.Ref != "" {
line += " | " + file.Ref
}
if file.ExtractStatus != "" {
line += " | " + file.ExtractStatus
}
if includeContent && file.Content != "" {
line += fmt.Sprintf("\n%s", truncateText(file.Content, 1200))
}
lines = append(lines, line)
}
return strings.Join(lines, "\n\n")
}

327
helper/after_sales_http.go Normal file
View File

@@ -0,0 +1,327 @@
package main
import (
"encoding/json"
"net/http"
)
func registerAfterSalesRoutes(router *http.ServeMux) {
router.HandleFunc("/api/after-sales/issues", handleAfterSalesIssues)
router.HandleFunc("/api/after-sales/issues/save", handleAfterSalesSaveIssue)
router.HandleFunc("/api/after-sales/issues/resolve", handleAfterSalesResolveIssue)
router.HandleFunc("/api/after-sales/issues/delete", handleAfterSalesDeleteIssue)
router.HandleFunc("/api/after-sales/collect", handleAfterSalesCollect)
router.HandleFunc("/api/after-sales/import-history", handleAfterSalesImportHistory)
router.HandleFunc("/api/after-sales/status", handleAfterSalesStatus)
router.HandleFunc("/api/after-sales/auto-collect", handleAfterSalesAutoCollect)
router.HandleFunc("/api/after-sales/knowledge/cases", handleAfterSalesKnowledgeCases)
router.HandleFunc("/api/after-sales/knowledge/cases/update", handleAfterSalesKnowledgeCaseUpdate)
router.HandleFunc("/api/after-sales/knowledge/cases/reveal", handleAfterSalesKnowledgeCaseReveal)
router.HandleFunc("/api/after-sales/dispatch/config", handleAfterSalesDispatchConfig)
router.HandleFunc("/api/after-sales/dispatch/queue", handleAfterSalesDispatchQueue)
router.HandleFunc("/api/after-sales/dispatch/assign", handleAfterSalesDispatchAssign)
router.HandleFunc("/api/after-sales/dispatch/notify", handleAfterSalesDispatchNotify)
router.HandleFunc("/api/after-sales/dispatch/batch-notify", handleAfterSalesDispatchBatchNotify)
}
func handleAfterSalesIssues(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "ok",
"data": getAfterSalesIssueEngine().snapshotIssues(),
})
}
func handleAfterSalesStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "ok",
"data": getAfterSalesIssueEngine().snapshotStatus(),
})
}
func handleAfterSalesSaveIssue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var issue AfterSalesIssue
if err := json.NewDecoder(r.Body).Decode(&issue); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
if err := getAfterSalesIssueEngine().saveIssue(issue); err != nil {
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "saved"})
}
func handleAfterSalesResolveIssue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
IssueID string `json:"issueId"`
ResolutionContent string `json:"resolutionContent"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
knowledgeCase, err := getAfterSalesIssueEngine().resolveIssue(payload.IssueID, payload.ResolutionContent)
if err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "已处理并保存到知识库", "data": knowledgeCase})
}
func handleAfterSalesDeleteIssue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
if !getAfterSalesIssueEngine().deleteIssue(payload.ID) {
sendJSONResponse(w, http.StatusNotFound, map[string]interface{}{"success": false, "message": "问题不存在"})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "deleted"})
}
func handleAfterSalesCollect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
ConversationID string `json:"conversationId"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&payload)
}
ok, message := getAfterSalesIssueEngine().triggerCollectAsync(payload.ConversationID, true)
status := http.StatusOK
if !ok {
status = http.StatusConflict
}
sendJSONResponse(w, status, map[string]interface{}{"success": ok, "message": message})
}
func handleAfterSalesImportHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload AfterSalesHistoryImportRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
result, err := getAfterSalesIssueEngine().importHistoryAndCollectDetailed(payload)
if err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": afterSalesHistoryImportMessage(result.Imported, result.Segments, result.Added),
"data": map[string]interface{}{
"imported": result.Imported,
"segments": result.Segments,
"added": result.Added,
"status": getAfterSalesIssueEngine().snapshotStatus(),
},
})
}
func handleAfterSalesAutoCollect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
if err := getAfterSalesIssueEngine().setAutoCollectEnabled(payload.Enabled); err != nil {
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "saved",
"data": getAfterSalesIssueEngine().snapshotStatus(),
})
}
func handleAfterSalesKnowledgeCases(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := getAfterSalesIssueEngine().syncResolvedKnowledgeCases(); err != nil {
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeCase{}})
return
}
cases, err := listAfterSalesKnowledgeCases()
if err != nil {
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeCase{}})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "ok", "data": cases})
}
func handleAfterSalesKnowledgeCaseUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
IssueID string `json:"issueId"`
ResolutionContent string `json:"resolutionContent"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
knowledgeCase, err := getAfterSalesIssueEngine().resolveIssue(payload.IssueID, payload.ResolutionContent)
if err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "知识案例已更新", "data": knowledgeCase})
}
func handleAfterSalesKnowledgeCaseReveal(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
IssueID string `json:"issueId"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
ok, message := revealAfterSalesKnowledgeCase(payload.IssueID)
status := http.StatusOK
if !ok {
status = http.StatusBadRequest
}
sendJSONResponse(w, status, map[string]interface{}{"success": ok, "message": message})
}
func handleAfterSalesDispatchConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := readAfterSalesDispatchConfig()
if err != nil {
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "ok", "data": cfg})
case http.MethodPost:
var cfg AfterSalesDispatchConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
if err := saveAfterSalesDispatchConfig(cfg); err != nil {
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "saved"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleAfterSalesDispatchQueue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
queue, err := getAfterSalesIssueEngine().dispatchQueue()
if err != nil {
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "ok", "data": queue})
}
func handleAfterSalesDispatchAssign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
IssueID string `json:"issueId"`
EngineerUserID string `json:"engineerUserId"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
if err := getAfterSalesIssueEngine().assignEngineer(payload.IssueID, payload.EngineerUserID); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
sendJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true, "message": "assigned"})
}
func handleAfterSalesDispatchNotify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
IssueID string `json:"issueId"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
result := getAfterSalesIssueEngine().notifyEngineer(payload.IssueID)
status := http.StatusOK
if !result.Success {
status = http.StatusBadRequest
}
sendJSONResponse(w, status, map[string]interface{}{"success": result.Success, "message": result.Message, "data": result})
}
func handleAfterSalesDispatchBatchNotify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
IssueIDs []string `json:"issueIds"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
sendJSONResponse(w, http.StatusBadRequest, map[string]interface{}{"success": false, "message": err.Error()})
return
}
result := getAfterSalesIssueEngine().batchNotifyEngineers(payload.IssueIDs)
sendJSONResponse(w, http.StatusOK, result)
}

236
helper/after_sales_image.go Normal file
View File

@@ -0,0 +1,236 @@
package main
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
var afterSalesImageKeys = map[string]struct{}{
"file_path": {},
"filepath": {},
"file_name": {},
"filename": {},
"path": {},
"local_path": {},
"localpath": {},
"image_path": {},
"imagepath": {},
"thumb_path": {},
"thumbnail": {},
"url": {},
"image_url": {},
"file_id": {},
"fileid": {},
"aes_key": {},
"md5": {},
"content": {},
"message": {},
"text": {},
}
func extractAfterSalesImage(raw map[string]interface{}) (string, string) {
values := collectImageLikeStrings(raw)
for _, value := range values {
if isLocalImagePath(value) {
return value, ""
}
}
for _, value := range values {
if looksLikeImageRef(value) {
return "", value
}
}
return "", ""
}
func extractAfterSalesImageFromMessage(msg autoReplyMessage, raw map[string]interface{}) (string, string) {
for _, value := range []string{msg.MediaLocalPath, msg.MediaFileName} {
if isLocalImagePath(value) {
return normalizedLocalImagePath(value), ""
}
}
if path, _ := extractAfterSalesImage(raw); path != "" {
return normalizedLocalImagePath(path), ""
}
if shouldDownloadAfterSalesImage(msg, raw) {
if path, err := ensureAutoReplyMediaLocalPath(msg); err == nil && isLocalImagePath(path) {
return normalizedLocalImagePath(path), ""
}
}
if _, ref := extractAfterSalesImage(raw); ref != "" {
return "", ref
}
for _, value := range []string{msg.MediaURL, msg.MediaFileID} {
if looksLikeImageRef(value) {
return "", value
}
}
return "", ""
}
func shouldDownloadAfterSalesImage(msg autoReplyMessage, raw map[string]interface{}) bool {
switch strings.TrimSpace(msg.MediaKind) {
case "image", "emoji":
return true
}
if msg.RawType == 11042 {
return true
}
if strings.TrimSpace(msg.MediaURL) != "" || strings.TrimSpace(msg.MediaFileID) != "" {
return looksLikeImageMessage(raw)
}
return false
}
func normalizedLocalImagePath(value string) string {
value = strings.Trim(strings.TrimSpace(value), "\"'")
if value == "" {
return ""
}
if filepath.IsAbs(value) {
return value
}
candidate := resolveAutoReplyPath(value)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
return value
}
func resolveAfterSalesImageRef(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if isLocalImagePath(value) {
return normalizedLocalImagePath(value)
}
lower := strings.ToLower(value)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return ""
}
parsed, err := url.Parse(value)
if err != nil {
return ""
}
ext := strings.ToLower(filepath.Ext(parsed.Path))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
default:
ext = ".jpg"
}
base := filepath.Base(parsed.Path)
if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" {
base = "after_sales_image"
}
savePath := generateSavePath("after_sales_images", base, ext)
if savePath == "" {
return ""
}
if err := downloadPlainMedia(value, savePath); err != nil {
return ""
}
if isLocalImagePath(savePath) {
return savePath
}
return ""
}
func looksLikeImageMessage(raw map[string]interface{}) bool {
if raw == nil {
return false
}
if typeVal, ok := raw["type"]; ok {
switch intFromAny(typeVal) {
case 11042, 11044, 11045, 11046:
return true
}
}
values := collectImageLikeStrings(raw)
for _, value := range values {
if isLocalImagePath(value) || looksLikeImageRef(value) {
return true
}
}
return false
}
func collectImageLikeStrings(value interface{}) []string {
var result []string
var walk func(interface{}, string)
walk = func(item interface{}, key string) {
switch v := item.(type) {
case map[string]interface{}:
for k, child := range v {
walk(child, strings.ToLower(strings.TrimSpace(k)))
}
case []interface{}:
for _, child := range v {
walk(child, key)
}
case string:
text := strings.TrimSpace(v)
if text == "" {
return
}
if _, ok := afterSalesImageKeys[key]; ok || isLocalImagePath(text) || looksLikeImageRef(text) {
result = append(result, text)
}
case fmt.Stringer:
text := strings.TrimSpace(v.String())
if text != "" {
result = append(result, text)
}
}
}
walk(value, "")
return uniqueNonEmptyStrings(result)
}
func isLocalImagePath(value string) bool {
value = strings.Trim(strings.TrimSpace(value), "\"'")
if value == "" {
return false
}
if strings.HasPrefix(strings.ToLower(value), "http://") || strings.HasPrefix(strings.ToLower(value), "https://") {
return false
}
ext := strings.ToLower(filepath.Ext(value))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
default:
return false
}
if filepath.IsAbs(value) {
if _, err := os.Stat(value); err == nil {
return true
}
return false
}
candidate := resolveAutoReplyPath(value)
if _, err := os.Stat(candidate); err == nil {
return true
}
return false
}
func looksLikeImageRef(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
lower := strings.ToLower(value)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
if parsed, err := url.Parse(value); err == nil {
ext := strings.ToLower(filepath.Ext(parsed.Path))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".bmp" || ext == ".webp" || strings.Contains(lower, "image")
}
return strings.Contains(lower, "image")
}
ext := strings.ToLower(filepath.Ext(value))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".bmp" || ext == ".webp" ||
strings.Contains(lower, "image") || strings.Contains(lower, "file_id") || strings.Contains(lower, "fileid")
}

View File

@@ -0,0 +1,341 @@
package main
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
)
var (
afterSalesHistoryHeaderPatterns = []*regexp.Regexp{
regexp.MustCompile(`^(.{1,40}?)\s+(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)\s+((?:今天|昨天|前天)\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
regexp.MustCompile(`^(.{1,40}?)[:]\s*(.*)$`),
}
afterSalesHistorySystemLine = regexp.MustCompile(`^(你|您)?(修改群名为|撤回了一条消息|加入群聊|退出群聊|邀请|移出|已读|以下为新消息)`)
)
type afterSalesHistoryParsedLine struct {
Sender string
Content string
SendAt int64
}
type afterSalesHistoryCollectResult struct {
Imported int
Segments int
Added int
}
func (e *AfterSalesIssueEngine) importHistoryAndCollect(req AfterSalesHistoryImportRequest) (int, int, error) {
result, err := e.importHistoryAndCollectDetailed(req)
return result.Imported, result.Added, err
}
func (e *AfterSalesIssueEngine) importHistoryAndCollectDetailed(req AfterSalesHistoryImportRequest) (afterSalesHistoryCollectResult, error) {
conversationID := normalizeAfterSalesCollectConversationID(req.ConversationID)
roomName := strings.TrimSpace(req.RoomName)
if conversationID == "" {
return afterSalesHistoryCollectResult{}, fmt.Errorf("请选择要导入的群聊")
}
if roomName == "" {
roomName = getAutoReplyEngine().ResolveGroupName(conversationID)
}
if roomName == "" {
roomName = conversationID
}
rawText := strings.TrimSpace(req.RawText)
if rawText == "" {
return afterSalesHistoryCollectResult{}, fmt.Errorf("请先粘贴企微群聊历史消息")
}
messages := parseAfterSalesHistoryMessages(conversationID, roomName, rawText, time.Now())
if len(messages) == 0 {
return afterSalesHistoryCollectResult{}, fmt.Errorf("未能从粘贴内容中解析出可导入的历史消息")
}
imported := e.mergeHistoryMessages(messages)
if imported == 0 {
return afterSalesHistoryCollectResult{}, nil
}
added, segments, err := e.collectHistoryMessages(conversationID, messages)
e.mu.Lock()
e.state.LastAddedCount = added
e.state.LastCollectedAt = time.Now().Unix()
if err != nil {
e.state.LastError = err.Error()
} else {
e.state.LastError = ""
e.repairIssuesLocked()
}
e.updateStateMessageCountLocked()
_ = e.saveStateLocked()
e.mu.Unlock()
return afterSalesHistoryCollectResult{Imported: imported, Segments: segments, Added: added}, err
}
func (e *AfterSalesIssueEngine) mergeHistoryMessages(messages []AfterSalesMessage) int {
now := time.Now()
e.mu.Lock()
defer e.mu.Unlock()
existing := make(map[string]int, len(e.messages))
for i, msg := range e.messages {
if strings.TrimSpace(msg.MessageID) != "" {
existing[msg.MessageID] = i
}
}
imported := 0
for _, msg := range messages {
if msg.MessageID == "" {
continue
}
if idx, ok := existing[msg.MessageID]; ok {
e.messages[idx] = msg
continue
}
e.messages = append(e.messages, msg)
existing[msg.MessageID] = len(e.messages) - 1
imported++
}
e.trimMessagesLocked(now)
e.updateStateMessageCountLocked()
if imported > 0 {
_ = e.saveMessagesLocked()
_ = e.saveStateLocked()
}
return imported
}
func (e *AfterSalesIssueEngine) collectHistoryMessages(conversationID string, importedMessages []AfterSalesMessage) (int, int, error) {
e.mu.Lock()
issues := append([]AfterSalesIssue(nil), e.issues...)
e.mu.Unlock()
cfg := getAutoReplyEngine().getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return 0, 0, fmt.Errorf("AI 配置未完整,无法收集售后问题")
}
existingFingerprints := make(map[string]AfterSalesIssue)
for _, issue := range issues {
if issue.Fingerprint != "" {
existingFingerprints[issue.Fingerprint] = issue
}
}
segments := segmentAfterSalesHistoryMessages(importedMessages)
added := 0
for _, segment := range segments {
if len(segment) == 0 {
continue
}
candidates, err := afterSalesAICollector(cfg.AI, segment)
if err != nil {
return added, len(segments), err
}
added += e.mergeAIIssueCandidates(candidates, segment, existingFingerprints)
}
return added, len(segments), nil
}
func segmentAfterSalesHistoryMessages(messages []AfterSalesMessage) [][]AfterSalesMessage {
var segments [][]AfterSalesMessage
for _, msg := range messages {
if !historyMessageLooksLikeIssue(msg) {
continue
}
segments = append(segments, []AfterSalesMessage{msg})
}
if len(segments) == 0 && len(messages) > 0 {
return batchAfterSalesMessages(messages, 6)
}
return segments
}
func historyMessageLooksLikeIssue(msg AfterSalesMessage) bool {
content := strings.TrimSpace(msg.Content)
if content == "" {
return false
}
if msg.SenderIdentity == senderIdentityInternal {
return false
}
lower := strings.ToLower(content)
noise := []string{"你好", "谢谢", "收到", "ok", "好的", "辛苦", "师傅好"}
for _, item := range noise {
if lower == strings.ToLower(item) {
return false
}
}
keywords := []string{
"坏", "故障", "报错", "不", "没", "无法", "不能", "失败", "异常", "卡", "掉线", "没电", "电压", "短接", "测试", "维修", "更换", "装不上", "启动", "低于",
}
for _, keyword := range keywords {
if strings.Contains(content, keyword) {
return true
}
}
return len([]rune(content)) >= 18
}
func parseAfterSalesHistoryMessages(conversationID, roomName, rawText string, importedAt time.Time) []AfterSalesMessage {
lines := strings.Split(strings.ReplaceAll(rawText, "\r\n", "\n"), "\n")
parsed := make([]afterSalesHistoryParsedLine, 0)
var current *afterSalesHistoryParsedLine
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || afterSalesHistorySystemLine.MatchString(line) {
continue
}
next, ok := parseAfterSalesHistoryLine(line, importedAt)
if ok {
if current != nil {
parsed = append(parsed, *current)
}
current = &next
continue
}
if current == nil {
current = &afterSalesHistoryParsedLine{Sender: "未知客户", SendAt: importedAt.Unix(), Content: line}
} else {
current.Content = strings.TrimSpace(current.Content + "\n" + line)
}
}
if current != nil {
parsed = append(parsed, *current)
}
messages := make([]AfterSalesMessage, 0, len(parsed))
for i, item := range parsed {
content := strings.TrimSpace(item.Content)
if content == "" {
continue
}
messageType := "text"
imageRef := ""
if looksLikeHistoryImageText(content) {
messageType = "image"
imageRef = content
}
sendAt := item.SendAt
if sendAt <= 0 {
sendAt = importedAt.Unix()
}
senderName := cleanAfterSalesHistorySender(item.Sender)
if senderName == "" {
senderName = "未知客户"
}
messages = append(messages, AfterSalesMessage{
MessageID: stableAfterSalesHistoryMessageID(conversationID, senderName, content, sendAt, i),
ClientID: 0,
ConversationID: conversationID,
RoomName: roomName,
SenderUserID: "history:" + senderName,
SenderName: senderName,
SenderIdentity: inferAfterSalesHistorySenderIdentity(senderName, content),
Content: content,
MessageType: messageType,
ImageRef: imageRef,
SendTime: sendAt,
ReceivedAt: importedAt.Unix(),
})
}
return messages
}
func parseAfterSalesHistoryLine(line string, base time.Time) (afterSalesHistoryParsedLine, bool) {
for idx, pattern := range afterSalesHistoryHeaderPatterns {
match := pattern.FindStringSubmatch(line)
if len(match) == 0 {
continue
}
sender := cleanAfterSalesHistorySender(match[1])
if sender == "" || looksLikeHistoryTime(sender) {
continue
}
if idx == 4 {
return afterSalesHistoryParsedLine{Sender: sender, Content: strings.TrimSpace(match[2]), SendAt: base.Unix()}, true
}
return afterSalesHistoryParsedLine{Sender: sender, SendAt: parseAfterSalesHistoryTime(match[2], base), Content: strings.TrimSpace(match[3])}, true
}
return afterSalesHistoryParsedLine{}, false
}
func parseAfterSalesHistoryTime(text string, base time.Time) int64 {
text = strings.TrimSpace(text)
replacements := map[string]string{"年": "-", "月": "-", "日": "", "/": "-"}
for old, next := range replacements {
text = strings.ReplaceAll(text, old, next)
}
if regexp.MustCompile(`^\d{1,2}-\d{1,2}\s+`).MatchString(text) {
text = fmt.Sprintf("%d-%s", base.Year(), text)
}
formats := []string{
"2006-1-2 15:04:05",
"2006-1-2 15:04",
}
for _, format := range formats {
if ts, err := time.ParseInLocation(format, text, time.Local); err == nil {
return ts.Unix()
}
}
day := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, base.Location())
switch {
case strings.HasPrefix(text, "昨天"):
day = day.AddDate(0, 0, -1)
text = strings.TrimSpace(strings.TrimPrefix(text, "昨天"))
case strings.HasPrefix(text, "前天"):
day = day.AddDate(0, 0, -2)
text = strings.TrimSpace(strings.TrimPrefix(text, "前天"))
case strings.HasPrefix(text, "今天"):
text = strings.TrimSpace(strings.TrimPrefix(text, "今天"))
}
if clock, err := time.ParseInLocation("15:04:05", text, base.Location()); err == nil {
return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), clock.Second(), 0, base.Location()).Unix()
}
if clock, err := time.ParseInLocation("15:04", text, base.Location()); err == nil {
return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), 0, 0, base.Location()).Unix()
}
return base.Unix()
}
func cleanAfterSalesHistorySender(sender string) string {
sender = strings.TrimSpace(sender)
sender = strings.TrimPrefix(sender, "@")
sender = strings.ReplaceAll(sender, "未命名企业", "")
sender = regexp.MustCompile(`\s+`).ReplaceAllString(sender, " ")
return strings.TrimSpace(sender)
}
func inferAfterSalesHistorySenderIdentity(senderName string, content string) string {
text := senderName + " " + content
internalHints := []string{"郑工", "梁师傅", "师傅", "工程师", "客服", "售后"}
for _, hint := range internalHints {
if strings.Contains(text, hint) && strings.Contains(content, "按") {
return senderIdentityInternal
}
}
return senderIdentityExternal
}
func looksLikeHistoryTime(text string) bool {
return regexp.MustCompile(`^\d{1,4}[-/:年月日\s]`).MatchString(strings.TrimSpace(text))
}
func looksLikeHistoryImageText(content string) bool {
content = strings.TrimSpace(strings.Trim(content, "[]【】"))
return content == "图片" || strings.EqualFold(content, "image")
}
func stableAfterSalesHistoryMessageID(conversationID, senderName, content string, sendAt int64, index int) string {
sum := sha1.Sum([]byte(fmt.Sprintf("history|%s|%s|%d|%d|%s", conversationID, senderName, sendAt, index, content)))
return "history:" + hex.EncodeToString(sum[:])
}
func afterSalesHistoryImportMessage(imported, segments, added int) string {
if imported == 0 {
return "历史消息已存在,没有新增导入"
}
return fmt.Sprintf("已同步 %d 条历史消息,分析 %d 段,新增 %d 条售后问题", imported, segments, added)
}

View File

@@ -0,0 +1,372 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
var afterSalesKnowledgeRebuildHook = func() {
if _, err := getAutoReplyEngine().rebuildKnowledgeIndex(); err != nil && globalLogger != nil {
globalLogger.Warn("[售后知识库] 重建自动客服知识索引失败: %v", err)
}
}
func (e *AfterSalesIssueEngine) resolveIssue(issueID string, resolutionContent string) (AfterSalesKnowledgeCase, error) {
issueID = strings.TrimSpace(issueID)
resolutionContent = strings.TrimSpace(resolutionContent)
if issueID == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空")
}
if resolutionContent == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案")
}
now := time.Now().Local().Format(time.RFC3339)
e.mu.Lock()
index := -1
for i := range e.issues {
if e.issues[i].ID == issueID {
index = i
break
}
}
if index < 0 {
e.mu.Unlock()
return AfterSalesKnowledgeCase{}, fmt.Errorf("问题不存在")
}
issue := e.issues[index]
normalizeAfterSalesDispatchFields(&issue)
issue.Status = afterSalesIssueStatusResolved
issue.ResolutionContent = resolutionContent
if strings.TrimSpace(issue.ResolvedAt) == "" {
issue.ResolvedAt = now
}
issue.UpdatedAt = now
knowledgeCase, err := upsertAfterSalesKnowledgeCase(issue, now)
if err != nil {
e.mu.Unlock()
return AfterSalesKnowledgeCase{}, err
}
issue.KnowledgeArchivedAt = knowledgeCase.KnowledgeArchivedAt
issue.KnowledgeSourcePath = knowledgeCase.MarkdownPath
e.issues[index] = issue
if err := e.saveIssuesLocked(); err != nil {
e.mu.Unlock()
return AfterSalesKnowledgeCase{}, err
}
e.mu.Unlock()
triggerAfterSalesKnowledgeRebuild()
return knowledgeCase, nil
}
func listAfterSalesKnowledgeCases() ([]AfterSalesKnowledgeCase, error) {
store, err := readAfterSalesKnowledgeCasesFile()
if err != nil {
return nil, err
}
cases := append([]AfterSalesKnowledgeCase(nil), store.Cases...)
for i := range cases {
cases[i] = normalizeAfterSalesKnowledgeCase(cases[i])
cases[i].MissingMarkdown = strings.TrimSpace(cases[i].MarkdownPath) == "" || !fileExists(cases[i].MarkdownPath)
}
sort.Slice(cases, func(i, j int) bool {
if cases[i].ResolvedAt != cases[j].ResolvedAt {
return cases[i].ResolvedAt > cases[j].ResolvedAt
}
return cases[i].IssueID > cases[j].IssueID
})
return cases, nil
}
func (e *AfterSalesIssueEngine) syncResolvedKnowledgeCases() error {
now := time.Now().Local().Format(time.RFC3339)
store, err := readAfterSalesKnowledgeCasesFile()
if err != nil {
return err
}
caseByIssueID := make(map[string]int)
for i, item := range store.Cases {
if id := strings.TrimSpace(item.IssueID); id != "" {
caseByIssueID[id] = i
}
}
e.mu.Lock()
changedIssues := false
changedCases := false
for i := range e.issues {
issue := &e.issues[i]
if strings.TrimSpace(issue.ID) == "" || normalizeAfterSalesStatus(issue.Status) != afterSalesIssueStatusResolved {
continue
}
if _, exists := caseByIssueID[issue.ID]; exists && strings.TrimSpace(issue.KnowledgeSourcePath) != "" {
continue
}
resolution := strings.TrimSpace(issue.ResolutionContent)
if resolution == "" {
resolution = strings.TrimSpace(issue.AISuggestion)
}
if resolution == "" {
resolution = strings.TrimSpace(issue.IssueContent)
}
if resolution == "" {
continue
}
if strings.TrimSpace(issue.ResolvedAt) == "" {
issue.ResolvedAt = firstNonEmpty(issue.UpdatedAt, now)
changedIssues = true
}
if strings.TrimSpace(issue.ResolutionContent) == "" {
issue.ResolutionContent = resolution
changedIssues = true
}
if strings.TrimSpace(issue.KnowledgeArchivedAt) == "" {
issue.KnowledgeArchivedAt = now
changedIssues = true
}
issue.KnowledgeSourcePath = afterSalesKnowledgeMarkdownPath(issue.ID)
changedIssues = true
knowledgeCase := knowledgeCaseFromIssue(*issue, issue.KnowledgeArchivedAt)
if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil {
e.mu.Unlock()
return err
}
if idx, exists := caseByIssueID[issue.ID]; exists {
store.Cases[idx] = knowledgeCase
} else {
store.Cases = append(store.Cases, knowledgeCase)
caseByIssueID[issue.ID] = len(store.Cases) - 1
}
changedCases = true
}
if changedCases {
sortAfterSalesKnowledgeCases(store.Cases)
if err := writeAfterSalesKnowledgeCasesFile(store); err != nil {
e.mu.Unlock()
return err
}
}
if changedIssues {
if err := e.saveIssuesLocked(); err != nil {
e.mu.Unlock()
return err
}
}
e.mu.Unlock()
if changedCases {
triggerAfterSalesKnowledgeRebuild()
}
return nil
}
func upsertAfterSalesKnowledgeCase(issue AfterSalesIssue, archivedAt string) (AfterSalesKnowledgeCase, error) {
issue.ID = strings.TrimSpace(issue.ID)
if issue.ID == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("issueId为空")
}
if strings.TrimSpace(issue.ResolutionContent) == "" {
return AfterSalesKnowledgeCase{}, fmt.Errorf("请填写最终处理方案")
}
if strings.TrimSpace(issue.ResolvedAt) == "" {
issue.ResolvedAt = archivedAt
}
knowledgeCase := knowledgeCaseFromIssue(issue, archivedAt)
if err := writeAfterSalesKnowledgeMarkdown(knowledgeCase); err != nil {
return AfterSalesKnowledgeCase{}, err
}
store, err := readAfterSalesKnowledgeCasesFile()
if err != nil {
return AfterSalesKnowledgeCase{}, err
}
replaced := false
for i := range store.Cases {
if store.Cases[i].IssueID == knowledgeCase.IssueID {
store.Cases[i] = knowledgeCase
replaced = true
break
}
}
if !replaced {
store.Cases = append(store.Cases, knowledgeCase)
}
sort.Slice(store.Cases, func(i, j int) bool {
if store.Cases[i].ResolvedAt != store.Cases[j].ResolvedAt {
return store.Cases[i].ResolvedAt > store.Cases[j].ResolvedAt
}
return store.Cases[i].IssueID > store.Cases[j].IssueID
})
if err := writeAfterSalesKnowledgeCasesFile(store); err != nil {
return AfterSalesKnowledgeCase{}, err
}
return knowledgeCase, nil
}
func knowledgeCaseFromIssue(issue AfterSalesIssue, archivedAt string) AfterSalesKnowledgeCase {
knowledgeCase := AfterSalesKnowledgeCase{
IssueID: issue.ID,
CreatedAt: issue.CreatedAt,
UpdatedAt: issue.UpdatedAt,
ResolvedAt: issue.ResolvedAt,
KnowledgeArchivedAt: archivedAt,
ConversationID: issue.ConversationID,
RoomName: issue.RoomName,
CustomerUserID: issue.CustomerUserID,
CustomerName: issue.CustomerName,
IssueContent: issue.IssueContent,
AISuggestion: issue.AISuggestion,
ResolutionContent: issue.ResolutionContent,
AssignedEngineerID: issue.AssignedEngineerID,
AssignedEngineerName: issue.AssignedEngineerName,
ImageCount: len(issue.ImagePaths) + len(issue.ImageRefs),
MarkdownPath: afterSalesKnowledgeMarkdownPath(issue.ID),
}
return normalizeAfterSalesKnowledgeCase(knowledgeCase)
}
func writeAfterSalesKnowledgeMarkdown(knowledgeCase AfterSalesKnowledgeCase) error {
if err := os.MkdirAll(filepath.Dir(knowledgeCase.MarkdownPath), 0755); err != nil {
return err
}
return os.WriteFile(knowledgeCase.MarkdownPath, []byte(renderAfterSalesKnowledgeMarkdown(knowledgeCase)), 0644)
}
func sortAfterSalesKnowledgeCases(cases []AfterSalesKnowledgeCase) {
sort.Slice(cases, func(i, j int) bool {
if cases[i].ResolvedAt != cases[j].ResolvedAt {
return cases[i].ResolvedAt > cases[j].ResolvedAt
}
return cases[i].IssueID > cases[j].IssueID
})
}
func normalizeAfterSalesKnowledgeCase(item AfterSalesKnowledgeCase) AfterSalesKnowledgeCase {
item.IssueID = strings.TrimSpace(item.IssueID)
item.CustomerName = normalizeAfterSalesDisplayName(item.CustomerName)
if strings.TrimSpace(item.MarkdownPath) == "" && item.IssueID != "" {
item.MarkdownPath = afterSalesKnowledgeMarkdownPath(item.IssueID)
}
return item
}
func readAfterSalesKnowledgeCasesFile() (afterSalesKnowledgeCasesFile, error) {
var store afterSalesKnowledgeCasesFile
if err := readJSONFile(afterSalesKnowledgeCasesPath(), &store); err != nil {
return store, err
}
for i := range store.Cases {
store.Cases[i] = normalizeAfterSalesKnowledgeCase(store.Cases[i])
}
return store, nil
}
func writeAfterSalesKnowledgeCasesFile(store afterSalesKnowledgeCasesFile) error {
return atomicWriteJSON(afterSalesKnowledgeCasesPath(), store)
}
func afterSalesKnowledgeCasesPath() string {
return resolveAutoReplyPath("config/after_sales_knowledge/cases.json")
}
func afterSalesKnowledgeMarkdownDir() string {
return resolveAutoReplyPath("config/knowledge/after_sales_cases")
}
func afterSalesKnowledgeMarkdownPath(issueID string) string {
return filepath.Join(afterSalesKnowledgeMarkdownDir(), safeAfterSalesKnowledgeFileID(issueID)+".md")
}
func safeAfterSalesKnowledgeFileID(value string) string {
value = strings.TrimSpace(value)
var builder strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '-' || r == '_':
builder.WriteRune(r)
}
}
if builder.Len() == 0 {
return fmt.Sprintf("issue-%d", time.Now().UnixNano())
}
return builder.String()
}
func renderAfterSalesKnowledgeMarkdown(item AfterSalesKnowledgeCase) string {
var builder strings.Builder
writeMarkdownLine := func(label string, value string) {
value = strings.TrimSpace(value)
if value == "" {
value = "-"
}
builder.WriteString(label)
builder.WriteString(value)
builder.WriteString("\n")
}
builder.WriteString("# 售后已处理案例\n\n")
writeMarkdownLine("问题ID", item.IssueID)
writeMarkdownLine("群聊:", item.RoomName)
writeMarkdownLine("客户:", item.CustomerName)
writeMarkdownLine("负责人:", displayAfterSalesKnowledgeEngineerName(item))
writeMarkdownLine("提出时间:", item.CreatedAt)
writeMarkdownLine("处理时间:", item.ResolvedAt)
writeMarkdownLine("图片数量:", fmt.Sprintf("%d", item.ImageCount))
builder.WriteString("\n## 问题\n\n")
builder.WriteString(strings.TrimSpace(item.IssueContent))
builder.WriteString("\n\n## 最终处理方案\n\n")
builder.WriteString(strings.TrimSpace(item.ResolutionContent))
builder.WriteString("\n\n## AI建议\n\n")
aiSuggestion := strings.TrimSpace(item.AISuggestion)
if aiSuggestion == "" {
aiSuggestion = "-"
}
builder.WriteString(aiSuggestion)
builder.WriteString("\n")
return builder.String()
}
func displayAfterSalesKnowledgeEngineerName(item AfterSalesKnowledgeCase) string {
if name := strings.TrimSpace(item.AssignedEngineerName); name != "" {
return name
}
if id := strings.TrimSpace(item.AssignedEngineerID); id != "" {
return id
}
return "-"
}
func revealAfterSalesKnowledgeCase(issueID string) (bool, string) {
issueID = strings.TrimSpace(issueID)
if issueID == "" {
return false, "issueId为空"
}
path := afterSalesKnowledgeMarkdownPath(issueID)
if !fileExists(path) {
return false, "知识案例文件不存在"
}
cmd := exec.Command("explorer.exe", path)
if err := cmd.Start(); err != nil {
return false, err.Error()
}
return true, "opened"
}
func triggerAfterSalesKnowledgeRebuild() {
go afterSalesKnowledgeRebuildHook()
}

View File

@@ -0,0 +1,209 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
func TestAfterSalesResolveIssueArchivesKnowledgeCase(t *testing.T) {
cleanupAfterSalesKnowledgeTestFiles(t)
rebuildCh := stubAfterSalesKnowledgeRebuild(t)
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
CreatedAt: "2026-06-04T09:00:00+08:00",
Status: afterSalesIssueStatusPending,
RoomName: "售后群",
CustomerName: "华南客户",
IssueContent: "镜头无法调焦",
AISuggestion: "检查调焦机构",
AssignedEngineerID: "engineer-a",
ImagePaths: []string{"a.jpg"},
}}}
knowledgeCase, err := engine.resolveIssue("i1", "已确认调焦环松动,重新固定后恢复。")
if err != nil {
t.Fatalf("resolve issue: %v", err)
}
if engine.issues[0].Status != afterSalesIssueStatusResolved {
t.Fatalf("expected resolved issue, got %#v", engine.issues[0])
}
if engine.issues[0].KnowledgeSourcePath == "" || engine.issues[0].KnowledgeArchivedAt == "" {
t.Fatalf("expected issue knowledge metadata, got %#v", engine.issues[0])
}
if knowledgeCase.IssueID != "i1" || knowledgeCase.ImageCount != 1 {
t.Fatalf("unexpected case: %#v", knowledgeCase)
}
data, err := os.ReadFile(knowledgeCase.MarkdownPath)
if err != nil {
t.Fatalf("read markdown: %v", err)
}
text := string(data)
for _, want := range []string{"问题IDi1", "镜头无法调焦", "已确认调焦环松动", "检查调焦机构", "售后群", "华南客户"} {
if !strings.Contains(text, want) {
t.Fatalf("expected markdown to contain %q, got %s", want, text)
}
}
cases, err := listAfterSalesKnowledgeCases()
if err != nil {
t.Fatalf("list cases: %v", err)
}
if len(cases) != 1 || cases[0].IssueID != "i1" {
t.Fatalf("expected one listed case, got %#v", cases)
}
select {
case <-rebuildCh:
case <-time.After(time.Second):
t.Fatal("expected knowledge rebuild hook")
}
}
func TestAfterSalesResolveIssueRequiresResolution(t *testing.T) {
cleanupAfterSalesKnowledgeTestFiles(t)
rebuildCh := stubAfterSalesKnowledgeRebuild(t)
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
Status: afterSalesIssueStatusPending,
RoomName: "售后群",
}}}
if _, err := engine.resolveIssue("i1", " "); err == nil {
t.Fatal("expected empty resolution error")
}
if engine.issues[0].Status != afterSalesIssueStatusPending {
t.Fatalf("expected status unchanged, got %#v", engine.issues[0])
}
select {
case <-rebuildCh:
t.Fatal("did not expect rebuild")
default:
}
}
func TestAfterSalesKnowledgeCaseOverwrite(t *testing.T) {
cleanupAfterSalesKnowledgeTestFiles(t)
stubAfterSalesKnowledgeRebuild(t)
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "i1",
CreatedAt: "2026-06-04T09:00:00+08:00",
Status: afterSalesIssueStatusPending,
IssueContent: "图像模糊",
AISuggestion: "检查焦距",
}}}
first, err := engine.resolveIssue("i1", "第一次处理方案")
if err != nil {
t.Fatalf("first resolve: %v", err)
}
second, err := engine.resolveIssue("i1", "第二次处理方案")
if err != nil {
t.Fatalf("second resolve: %v", err)
}
if first.MarkdownPath != second.MarkdownPath {
t.Fatalf("expected same markdown path, got %s and %s", first.MarkdownPath, second.MarkdownPath)
}
cases, err := listAfterSalesKnowledgeCases()
if err != nil {
t.Fatalf("list cases: %v", err)
}
if len(cases) != 1 || cases[0].ResolutionContent != "第二次处理方案" {
t.Fatalf("expected overwritten single case, got %#v", cases)
}
data, err := os.ReadFile(second.MarkdownPath)
if err != nil {
t.Fatalf("read markdown: %v", err)
}
if strings.Contains(string(data), "第一次处理方案") || !strings.Contains(string(data), "第二次处理方案") {
t.Fatalf("expected markdown overwrite, got %s", string(data))
}
}
func TestAfterSalesKnowledgeSyncLegacyResolvedIssue(t *testing.T) {
cleanupAfterSalesKnowledgeTestFiles(t)
rebuildCh := stubAfterSalesKnowledgeRebuild(t)
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
ID: "legacy",
CreatedAt: "2026-06-02T11:10:24+08:00",
UpdatedAt: "2026-06-04T11:03:34+08:00",
Status: afterSalesIssueStatusResolved,
RoomName: "刘羽、JM.、C",
CustomerName: "JM.",
IssueContent: "客户询问客服身份",
AISuggestion: "客户询问客服身份,需确认是否需要进一步解释或提供帮助",
AssignedEngineerID: "1688855899845302",
NotifyStatus: afterSalesNotifySent,
}}}
if err := engine.syncResolvedKnowledgeCases(); err != nil {
t.Fatalf("sync legacy resolved: %v", err)
}
cases, err := listAfterSalesKnowledgeCases()
if err != nil {
t.Fatalf("list cases: %v", err)
}
if len(cases) != 1 || cases[0].IssueID != "legacy" {
t.Fatalf("expected one synced legacy case, got %#v", cases)
}
if cases[0].ResolutionContent != "客户询问客服身份,需确认是否需要进一步解释或提供帮助" {
t.Fatalf("expected AI suggestion fallback, got %#v", cases[0])
}
if engine.issues[0].KnowledgeSourcePath == "" || engine.issues[0].ResolutionContent == "" {
t.Fatalf("expected issue backfill metadata, got %#v", engine.issues[0])
}
if !fileExists(cases[0].MarkdownPath) {
t.Fatalf("expected markdown file at %s", cases[0].MarkdownPath)
}
select {
case <-rebuildCh:
case <-time.After(time.Second):
t.Fatal("expected knowledge rebuild hook")
}
}
func TestAfterSalesKnowledgeCaseMissingMarkdownFlag(t *testing.T) {
cleanupAfterSalesKnowledgeTestFiles(t)
stubAfterSalesKnowledgeRebuild(t)
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "报错"}}}
knowledgeCase, err := engine.resolveIssue("i1", "重启设备恢复")
if err != nil {
t.Fatalf("resolve issue: %v", err)
}
if err := os.Remove(knowledgeCase.MarkdownPath); err != nil {
t.Fatalf("remove markdown: %v", err)
}
cases, err := listAfterSalesKnowledgeCases()
if err != nil {
t.Fatalf("list cases: %v", err)
}
if len(cases) != 1 || !cases[0].MissingMarkdown {
t.Fatalf("expected missing markdown flag, got %#v", cases)
}
}
func stubAfterSalesKnowledgeRebuild(t *testing.T) chan struct{} {
t.Helper()
oldHook := afterSalesKnowledgeRebuildHook
ch := make(chan struct{}, 8)
afterSalesKnowledgeRebuildHook = func() {
ch <- struct{}{}
}
t.Cleanup(func() {
afterSalesKnowledgeRebuildHook = oldHook
})
return ch
}
func cleanupAfterSalesKnowledgeTestFiles(t *testing.T) {
t.Helper()
t.Cleanup(func() {
_ = os.Remove(afterSalesKnowledgeCasesPath())
_ = os.Remove(afterSalesIssuesPath())
_ = os.RemoveAll(afterSalesKnowledgeMarkdownDir())
})
_ = os.Remove(afterSalesKnowledgeCasesPath())
_ = os.Remove(afterSalesIssuesPath())
_ = os.RemoveAll(afterSalesKnowledgeMarkdownDir())
}

Some files were not shown because too many files have changed in this diff Show More