chore(build): 更新.gitignore配置和清理Wails临时文件

- 添加dist/目录到.gitignore,用于排除打包输出的绿色免安装版
- 添加Wails打包过程中的临时文件和自动生成文件到.gitignore
- 删除build/windows/installer/wails_tools.nsh自动生成文件
- 添加Windows安装器临时目录和Webview2安装文件到忽略列表

feat(docs): 添加万川平台对接文档和产品素材

- 创建万川平台登录到获取模型信息的流程说明文档
- 添加万川平台对接实施计划文档
- 新增产品图片、公司简介图、宣传海报、教程截图、案例展示等素材文件

refactor(runtime): 扩展通知功能类型定义

- 添加NotificationOptions接口定义
- 添加NotificationAction接口定义
- 添加NotificationCategory接口定义
- 扩展通知相关的运行时API类型声明,包括初始化、发送、注册分类等功能
This commit is contained in:
2026-06-25 18:13:11 +08:00
parent 858cb68f4f
commit a926ee6b1b
34 changed files with 1178 additions and 275 deletions

7
.gitignore vendored
View File

@@ -5,10 +5,17 @@ frontend/node_modules/
# Frontend build output
frontend/dist/
# Packaging output (绿色免安装版)
dist/
# Wails / build output
build/bin/
build/tmp/
build/windows/installer/runtime/
# Wails 打包时自动生成/下载的中间产物,每次构建都会变
build/windows/installer/tmp/
build/windows/installer/wails_tools.nsh
build/windows/installer/MicrosoftEdgeWebview2Setup.exe
*.syso
# Go cache and test output

View File

@@ -1,236 +0,0 @@
# 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,272 @@
# 万川 AI 平台:从账号密码登录到获取模型信息
> 本文基于当前未提交的代码梳理,描述用户在「设置」页填入平台地址 + 账号密码后,
> 系统如何登录、拉取模型配置并写入后端 AI 设置的完整链路。
## 一、整体链路总览
```
用户填写平台地址 / 账号 / 密码
[1] POST /api/login ───────────────► 返回 token缓存到 localStorage
[2] 登录成功后并行触发:
├─ GET /api/system/kb/relation/query 拉取岗位知识库列表
└─ syncModel() 同步模型配置
[3] 按模型编码 code 分别拉取(携带 token
├─ GET /api/system/model/getByCode/generic 通用/聊天模型(必拉)
├─ GET /api/system/model/getByCode/vision 视觉模型(可选)
└─ GET /api/system/model/getByCode/voice 语音模型(可选)
[4] 解析 providerModels.encryptedConfig(JSON 字符串)
→ { modelName, apiKey, endpointUrl }
[5] PUT /api/settings 写入后端 SQLiteapp_settings 表)
[6] 后端聊天/视觉/语音调用时按用途读取这些配置(带回退)
```
前端实现:[chatlab-web/frontend/src/api/wanchuan.js](chatlab-web/frontend/src/api/wanchuan.js)
触发位置:[chatlab-web/frontend/src/pages/SettingsPage.jsx](chatlab-web/frontend/src/pages/SettingsPage.jsx)
后端设置:[chatlog_fastAPI/routers/settings.py](chatlog_fastAPI/routers/settings.py)
---
## 二、第 1 步:登录获取 token
### 接口
```
POST {platformUrl}/api/login
Content-Type: application/json;charset=UTF-8
```
### 请求体
```json
{
"username": "用户名",
"password": "密码",
"loginType": "user"
}
```
### 响应(兼容多种结构)
代码用以下顺序提取 token任一命中即可
```
result.token
result.data.token
result.data.access_token
result.access_token
```
典型返回结构:
```json
{
"code": 200,
"msg": "success",
"token": "eyJhbGci...",
"data": {
"token": "eyJhbGci...",
"modules": [ /* */ ]
}
}
```
### token 的存储与使用
- 登录成功后 token 写入 `localStorage`key 为 `chatlab_wanchuan_token`
- 后续所有平台接口都带两个头(平台同时认这两种):
```
Authorization: Bearer {token}
token: Bearer {token}
```
对应函数:`wanchuanLogin(baseUrl, username, password)`
[wanchuan.js:39](chatlab-web/frontend/src/api/wanchuan.js#L39)
---
## 三、第 2 步:登录的触发与自动恢复
登录在 [SettingsPage.jsx](chatlab-web/frontend/src/pages/SettingsPage.jsx) 的 `WanchuanPlatformForm` 组件里触发,分两种场景:
### 场景 A用户点「测试连接」`handleTestConnection`
1. 调用 `wanchuanLogin()` 登录。
2. 成功后并行执行:
- `fetchKnowledgeBases()` 拉知识库列表
- `syncModel()` 同步平台模型配置 → 写入后端 AI 设置
### 场景 B打开设置页自动恢复`autoLogin`
- 配置(平台地址/账号/密码/已选知识库)以**后端 SQLite 为唯一数据源**
通过 `GET /api/settings/wanchuan` 读取,不依赖前端 originexe 端口变化也能恢复)。
- 挂载时若已有保存的账号密码,自动登录并拉知识库。
- **注意**:自动恢复时**不**自动 `syncModel`避免覆盖用户在「AI 模型配置」里手改并保存过的值。模型同步只在用户显式操作(点「测试连接」或「从平台获取」)时进行。
---
## 四、第 3-4 步:按模型编码获取模型信息
这是「获取模型信息」的核心。平台按**用途**用不同的模型编码code区分
| code | 用途 | 同步到的后端字段 |
|-----------|------------|-----------------------------------------------|
| `generic` | 通用/聊天 | `ai_*`(话题分析)+ `summary_*`(报告生成) |
| `vision` | 视觉 | `vision_*`(图片/视频描述) |
| `voice` | 语音 | `voice_*`(语音转文字) |
### 接口
```
GET {platformUrl}/api/system/model/getByCode/{code}
Authorization: Bearer {token}
token: Bearer {token}
```
### 响应结构
```json
{
"code": 200,
"msg": "success",
"data": {
"providerModels": {
"modelName": "qwen-plus",
"encryptedConfig": "{\"apiKey\":\"sk-xxx\",\"endpointUrl\":\"https://...../v1\"}"
}
}
}
```
关键点:`data.providerModels.encryptedConfig` 是一个 **JSON 字符串**,需二次 `JSON.parse` 才能拿到 `apiKey` 和 `endpointUrl`。
### 解析后得到
```js
{
modelName: pm.modelName, // 模型名
apiKey: cfg.apiKey, // 来自 encryptedConfig
endpointUrl: cfg.endpointUrl, // 来自 encryptedConfig
}
```
对应函数:`getWanchuanModelConfig(baseUrl, code)`
[wanchuan.js:110](chatlab-web/frontend/src/api/wanchuan.js#L110)
错误处理:
- HTTP 非 2xx → 抛 `获取模型[{code}]失败 HTTP {status}`(便于排查 404 路径错 / 401 token 失效)。
- 无 `providerModels` → 抛 `平台未返回模型[{code}]配置`。
---
## 五、第 5 步:写入后端 AI 设置
`syncWanchuanModelToSettings()` 负责编排三次拉取并合并写入:
1. **generic 必拉**:拿到 `endpointUrl / apiKey / modelName` 后,同时写入两组字段:
- 话题分析:`ai_base_url` / `ai_api_key` / `ai_model`
- 报告生成:`summary_base_url` / `summary_api_key` / `summary_model`
(两者同网关,但分开存便于各组独立回显与覆盖。)
2. **vision / voice 可选**:用 `Promise.all` 并行拉取,**失败则跳过对应字段**`.catch(() => {})`),不打断聊天模型同步:
- `vision_base_url` / `vision_api_key` / `vision_model`
- `voice_base_url` / `voice_api_key` / `voice_model`
3. 仅写入**非空字段**;若最终 payload 为空则抛 `平台模型配置为空`。
4. 最后 `PUT /api/settings` 保存到后端:
```
PUT /api/settings
Content-Type: application/json
```
```json
{
"ai_base_url": "...", "ai_api_key": "...", "ai_model": "...",
"summary_base_url": "...", "summary_api_key": "...", "summary_model": "...",
"vision_base_url": "...", "vision_api_key": "...", "vision_model": "...",
"voice_base_url": "...", "voice_api_key": "...", "voice_model": "..."
}
```
对应函数:`syncWanchuanModelToSettings(baseUrl, codes)`
[wanchuan.js:151](chatlab-web/frontend/src/api/wanchuan.js#L151)
---
## 六、后端 /api/settings 的处理
实现在 [chatlog_fastAPI/routers/settings.py](chatlog_fastAPI/routers/settings.py)。
### 可编辑字段EDITABLE_KEYS
```
ai_base_url, ai_api_key, ai_model, summary_model,
vision_model, voice_model, topic_analysis_prompt,
voice_base_url, voice_api_key, vision_base_url, vision_api_key,
summary_base_url, summary_api_key
```
### 密钥脱敏SECRET_KEYS
`ai_api_key / voice_api_key / vision_api_key / summary_api_key` 四个字段:
- **GET** 返回时打码(`_mask_key`,只保留首尾、中间打 `*`)。
- **PUT** 时若值里含 `*`,说明是 GET 返回的打码值(用户没真改),**跳过不写**,避免把真实密钥覆盖成打码串。
### 存储
所有字段以 key-value 形式存进 SQLite 的 `app_settings` 表(`INSERT ... ON CONFLICT DO UPDATE`)。
---
## 七、第 6 步:后端运行时如何使用这些配置
实现在 [chatlog_fastAPI/services/ai_client.py](chatlog_fastAPI/services/ai_client.py) 与
[chatlog_fastAPI/services/runtime_settings.py](chatlog_fastAPI/services/runtime_settings.py)。
- `get_ai_settings()`:从 SQLite 读出全部 AI 字段(带默认值),并缓存。
- `get_openai_client()`:聊天类调用(话题分析 / 报告 / 总结 / 对话)用全局
`ai_base_url` + `ai_api_key`。
- `get_client_for(purpose)``purpose` 为 `voice` / `vision`
优先用 `{purpose}_base_url` / `{purpose}_api_key`**为空则回退**到
`ai_base_url` / `ai_api_key`(单网关场景无需重复配。
- 客户端按 `(base_url, api_key)` 缓存,聊天/视觉/语音可指向不同网关与密钥,
最多累积 3 个,配置变更后自然生成新客户端。
---
## 八、相关接口速查表
| 步骤 | 方法 | 路径 | 用途 |
|------|------|------|------|
| 登录 | POST | `{platformUrl}/api/login` | 账号密码换 token |
| 知识库 | GET | `{platformUrl}/api/system/kb/relation/query` | 拉岗位知识库列表 |
| 模型信息 | GET | `{platformUrl}/api/system/model/getByCode/{code}` | 按 code 取模型配置 |
| 上传 | POST | `{platformUrl}/api/system/kb/file/upload/async/{datasetId}` | 上传文件到知识库 |
| 读平台配置 | GET | `/api/settings/wanchuan` | 后端存的平台地址/账号/密码/已选库 |
| 存平台配置 | PUT | `/api/settings/wanchuan` | 保存平台配置 |
| 读 AI 配置 | GET | `/api/settings` | 回显 AI 模型配置(密钥脱敏) |
| 写 AI 配置 | PUT | `/api/settings` | 保存/同步 AI 模型配置 |
---
## 九、关键数据结构小结
**登录请求**`{ username, password, loginType: "user" }`
**登录响应取 token**`result.token` 等多路径兜底
**模型接口响应**`data.providerModels.{ modelName, encryptedConfig }`
**encryptedConfig**JSON 字符串):`{ apiKey, endpointUrl, ... }`
**最终归一化模型对象**`{ modelName, apiKey, endpointUrl }`
**写入后端的 12 个字段**`{ai,summary,vision,voice}_{base_url,api_key,model}`

View File

@@ -0,0 +1,108 @@
# 万川平台对接 — 实施计划
> 本文为对接万川 AI 平台(登录 → 拉取模型配置)的实施计划。
> 接口契约见同目录 [万川平台-登录到获取模型信息-流程说明.md](万川平台-登录到获取模型信息-流程说明.md)。
## 范围
### 本期做
登录平台 → 拉模型配置 → **log 打印**。平台凭证可编辑、可持久化、可重置。
### 本期不做(留第二期)
- 回填 AI 表单(`form.ai.*`
-`SaveAutoReplyConfig` 落盘 + 推 helper 重载
理由:先打印确认平台返回的数据结构与解析结果正确,再接回填与保存。
## 架构决策
- **Go 只做透传代理 + 凭证存取**,不碰业务解析。仅用于绕开 WebView 的 CORS 限制。
- **业务逻辑全在前端**:取 token、按 code 拉取、解析 `encryptedConfig`、字段映射。
- **持久化存 `config/config.json`**exe 同级,重启自动恢复,与浏览器无关),新增 `platformConfig` 段。不使用 localStorage。
- ⚠️ 密码以明文存在 config.json本地桌面工具常见做法。如需更稳可加简单混淆但非真加密。
## 一、Go 端
新文件:`wanchuan_proxy.go`(与 [app.go](../../app.go) 同级main 包)。
### 1.1 透传代理方法(绑定到 App
| 方法 | 行为 |
|------|------|
| `WanchuanLogin(baseUrl, username, password string) string` | POST `{baseUrl}/api/login`body `{username, password, loginType:"user"}`**原样返回**平台响应 JSON 字符串;日志打印(密码/token 打码) |
| `WanchuanGetModel(baseUrl, code, token string) string` | GET `{baseUrl}/api/system/model/getByCode/{code}`;带头 `Authorization: Bearer {token}``token: Bearer {token}`**原样返回**响应 JSON日志打印 |
不解析 token、不合并字段、不存盘——平台返什么就返什么。
### 1.2 凭证存取方法(绑定到 App
| 方法 | 行为 |
|------|------|
| `GetPlatformConfig() interface{}` | 读 config.json 的 `platformConfig` 段回前端 |
| `SavePlatformConfig(jsonData string) (bool, string)` | 存 `platformConfig` 段(重置 = 存空值) |
### 1.3 config 结构改动
[config/types.go](../types.go) 新增结构并挂到 `Config`
```go
type PlatformConfig struct {
BaseURL string `json:"baseUrl"`
Username string `json:"username"`
Password string `json:"password"`
}
// Config 增加字段:
// PlatformConfig PlatformConfig `json:"platformConfig"`
```
[config/config_manager.go](../config_manager.go) 新增 `UpdatePlatformConfig`,复用现有 `SaveGlobalConfig`
> 改了 App 方法后需跑 `wails dev` / `wails build` 重新生成 `frontend/wailsjs/go/main/App.js` 绑定。
## 二、前端
文件:[frontend/src/components/AutoReply.vue](../../frontend/src/components/AutoReply.vue),在 AI 配置区(`#auto-section-ai`02顶部新增「万川平台」卡片。
### 2.1 UI
- 输入框:平台地址 / 账号 / 密码(绑 reactive挂载时 `GetPlatformConfig` 回填,可编辑)
- 按钮「登录并获取模型」
- 按钮「重置」:清空凭证(`SavePlatformConfig` 存空)后重新走一次流程
### 2.2 「登录并获取模型」逻辑
1. `SavePlatformConfig(当前输入)` 先存凭证
2. `WanchuanLogin(url, user, pwd)``JSON.parse` → 多路径取 token
`token` / `data.token` / `data.access_token` / `access_token`
3. 依次 `WanchuanGetModel(url, code, token)`
- `generic`(必拉)
- `vision``voice`(可选,失败跳过)
4. 每个响应取 `data.providerModels`,对 `encryptedConfig` 做**二次 `JSON.parse`**
`{ modelName, apiKey, endpointUrl }`
5. **`console.log` + `LogFrontend` 打印**原始响应与解析结果 —— **到此为止。**
### 2.3 字段映射(供第二期参考)
| 万川 code | 解析字段 | AIConfig 字段 |
|-----------|---------|---------------|
| `generic` | endpointUrl / apiKey / modelName | `baseUrl` / `apiKey` / `model` |
| `vision` | endpointUrl / apiKey / modelName | `visionBaseUrl` / `visionApiKey` / `visionModel` |
| `voice` | endpointUrl / apiKey / modelName | `audioBaseUrl` / `audioApiKey` / `audioModel` |
`summary_*` 无对应业务,忽略;知识库列表不拉。)
## 三、验证
- `go build` 通过;`wails dev` 起来后在 AI 配置区填真实平台地址/账号/密码,点「登录并获取模型」,
看浏览器控制台和后端日志打印出 generic/vision/voice 的解析结果,确认 `endpointUrl/apiKey/modelName` 拿对。
- 重启应用,确认账号密码自动回填(持久化生效)。
- 点「重置」,确认凭证清空。
## 四、第二期(确认数据无误后)
把 2.2 第 5 步从"打印"改为:
- 按 2.3 映射回填 `form.ai.*`
- 调现有 `SaveAutoReplyConfig(form)` 落盘 + 推 helper 重载
至此 AI 配置实现平台动态下发。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1 +1 @@
db031e671111b343255373ca05cff100
3ae7140e534eadc042a92a3db16fd10c

View File

@@ -247,3 +247,84 @@ export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -48,6 +48,10 @@ export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
@@ -236,3 +240,59 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

5
go.mod
View File

@@ -5,13 +5,14 @@ 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/wailsapp/wails/v2 v2.12.0
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
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
@@ -35,7 +36,7 @@ require (
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/go-webview2 v1.0.22 // 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

10
go.sum
View File

@@ -1,3 +1,5 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
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=
@@ -61,12 +63,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
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/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/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/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
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=

View File

@@ -351,9 +351,56 @@ func extractAutoReplyMessage(clientID int32, raw map[string]interface{}) autoRep
} else {
msg.MessageType = "non_text"
}
logEmptyMediaDiagnostics(msg, raw)
return msg
}
// logEmptyMediaDiagnostics 仅在"图片/视频/表情等非文本媒体消息,且 URL/本地路径/FileID 三个来源全空"
// 时触发,把媒体相关字段(已脱敏)打到日志,用于定位企微 DLL 回调实际下发了哪些字段。
// 文本消息(11041)与字段已成功填充的消息都不会触发,避免刷屏与泄露正常聊天内容。
func logEmptyMediaDiagnostics(msg autoReplyMessage, raw map[string]interface{}) {
if globalLogger == nil {
return
}
// 只关心需要拉取媒体文件来识别的类型;纯文本/位置等不在此列。
switch msg.RawType {
case 11042, 11043, 11047: // image / video / link(图片/表情)
default:
return
}
hasMediaSource := strings.TrimSpace(msg.MediaURL) != "" ||
strings.TrimSpace(msg.MediaLocalPath) != "" ||
strings.TrimSpace(msg.MediaFileID) != ""
if hasMediaSource {
return // 字段已填到,下载链路可以走,无需诊断
}
// 只提取诊断相关的字段,避免泄露用户昵称、群名、会话内容等敏感信息
diagnosticFields := make(map[string]interface{})
diagnosticFields["event"] = raw["event"]
diagnosticFields["type"] = raw["type"]
if data, ok := raw["data"].(map[string]interface{}); ok {
// 只记录媒体相关字段和类型标识
for _, key := range []string{"event", "content_type", "contentType", "messageType", "message_type",
"image_url", "imageUrl", "preview_img_url", "previewImgUrl",
"md_url", "mdUrl", "ld_url", "ldUrl", "url",
"file_id", "fileId", "local_path", "localPath",
"media_kind", "mediaKind", "aes_key", "aesKey", "auth_key", "authKey"} {
if val, exists := data[key]; exists && val != nil {
diagnosticFields[key] = val
}
}
}
diagJSON, err := json.Marshal(diagnosticFields)
if err != nil {
globalLogger.Warn("[媒体诊断] rawType=%d 媒体字段全空,且诊断数据序列化失败: %v", msg.RawType, err)
return
}
globalLogger.Warn("[媒体诊断] rawType=%d mediaKind=%s 媒体字段(URL/本地路径/FileID)全空,无法识别。诊断字段: %s",
msg.RawType, msg.MediaKind, string(diagJSON))
}
func rawTypeFromEvent(raw map[string]interface{}) int {
event := strings.TrimSpace(stringFromAny(raw["event"]))
if event == "" {
@@ -361,6 +408,7 @@ func rawTypeFromEvent(raw map[string]interface{}) int {
event = strings.TrimSpace(stringFromAny(data["event"]))
}
}
// 优先使用 event 字段DLL 真实事件20002=文本 20003=图片 20004=视频 20012=语音 20005=文件 20014=链接
switch event {
case "20002":
return 11041
@@ -375,18 +423,24 @@ func rawTypeFromEvent(raw map[string]interface{}) int {
case "20014":
return 11047
}
// event 为空时才用 content_type模拟事件或老版本 DLL2=文本 101=图片 103=视频 16=语音 102=文件 6=位置 13=链接
// 注意不能把文本(2)误判成图片,否则会触发图片识别并回退"无法识别"话术。
if data, ok := raw["data"].(map[string]interface{}); ok {
switch intFromAny(firstNonNil(data["messageType"], data["content_type"], data["contentType"])) {
switch intFromAny(firstNonNil(data["content_type"], data["contentType"])) {
case 2:
return 11041
case 101:
return 11042
case 4:
case 103:
return 11043
case 16:
return 11044
case 6:
case 102:
return 11045
case 48:
case 6:
return 11046
case 13:
return 11047
}
}
return 0

View File

@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
@@ -286,36 +287,54 @@ func syncAutoReplyMaterials(root string, indexPath string) (autoReplyMaterialSyn
func discoverAutoReplyMaterials(root string) []AutoReplyMaterial {
dir := resolveAutoReplyPath(root)
entries, err := os.ReadDir(dir)
items := make([]AutoReplyMaterial, 0, 8)
// 递归遍历子目录filepath.WalkDir支持 config/materials 下任意层级嵌套。
// Path 存相对 root 的子路径并统一为 / 分隔;顶层文件相对路径即文件名,向后兼容旧索引。
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // 单个条目出错跳过,不中断整体扫描
}
if d.IsDir() {
return nil
}
items := make([]AutoReplyMaterial, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
name := d.Name()
if strings.EqualFold(name, "materials.json") {
continue
return nil
}
materialType := inferMaterialType(name)
if materialType == "" {
continue
return nil
}
rel, relErr := filepath.Rel(dir, path)
if relErr != nil {
rel = name
}
rel = filepath.ToSlash(rel)
title := strings.TrimSuffix(name, filepath.Ext(name))
keywords := defaultMaterialKeywords(title, materialType)
// 把子目录名也并入关键词,便于"发我<目录名>的图/文件"命中。
// 只添加目录名本身,不再分词,避免关键词过度膨胀。
if dirPart := filepath.ToSlash(filepath.Dir(rel)); dirPart != "." && dirPart != "" {
for _, seg := range strings.Split(dirPart, "/") {
if seg = strings.TrimSpace(seg); seg != "" {
keywords = append(keywords, seg)
}
}
keywords = dedupeNonEmptyStrings(keywords)
}
items = append(items, AutoReplyMaterial{
ID: materialIDFromTitle(title),
ID: materialIDFromTitle(strings.TrimSuffix(rel, filepath.Ext(rel))),
Title: title,
Keywords: defaultMaterialKeywords(title, materialType),
Keywords: keywords,
QuestionPatterns: defaultMaterialQuestionPatterns(title),
MaterialType: materialType,
Path: name,
Path: rel,
Caption: defaultMaterialCaption(materialType),
Priority: 1,
Enabled: true,
})
}
return nil
})
return normalizeAutoReplyMaterials(items)
}

60
install-nsis.ps1 Normal file
View File

@@ -0,0 +1,60 @@
# 自动下载安装 NSIS
$ErrorActionPreference = "Stop"
Write-Host "==> 检查 NSIS 是否已安装..." -ForegroundColor Cyan
$existingNsis = Get-Command makensis.exe -ErrorAction SilentlyContinue
if ($existingNsis) {
Write-Host " NSIS 已安装: $($existingNsis.Source)" -ForegroundColor Green
Write-Host " 跳过安装,可以直接执行打包命令。" -ForegroundColor Yellow
exit 0
}
$nsisVersion = "3.10"
$nsisUrl = "https://sourceforge.net/projects/nsis/files/NSIS%203/$nsisVersion/nsis-$nsisVersion-setup.exe/download"
$installerPath = "$env:TEMP\nsis-$nsisVersion-setup.exe"
Write-Host "==> 下载 NSIS $nsisVersion 安装程序..." -ForegroundColor Cyan
Write-Host " 从: $nsisUrl" -ForegroundColor Gray
Write-Host " 到: $installerPath" -ForegroundColor Gray
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $nsisUrl -OutFile $installerPath -UseBasicParsing -TimeoutSec 300
Write-Host " 下载完成 ($('{0:N2}' -f ((Get-Item $installerPath).Length / 1MB)) MB)" -ForegroundColor Green
} catch {
Write-Host " 下载失败: $($_.Exception.Message)" -ForegroundColor Red
Write-Host ""
Write-Host "请手动下载并安装 NSIS" -ForegroundColor Yellow
Write-Host " 1. 访问: https://nsis.sourceforge.io/Download" -ForegroundColor Cyan
Write-Host " 2. 下载 NSIS 3.10 或更高版本" -ForegroundColor Cyan
Write-Host " 3. 运行安装程序(默认安装路径即可)" -ForegroundColor Cyan
exit 1
}
Write-Host ""
Write-Host "==> 正在安装 NSIS..." -ForegroundColor Cyan
Write-Host " 安装位置: C:\Program Files (x86)\NSIS" -ForegroundColor Gray
Write-Host " (如果弹出 UAC 提示,请点击'是'授权)" -ForegroundColor Yellow
try {
Start-Process -FilePath $installerPath -ArgumentList "/S" -Wait -Verb RunAs
Write-Host " 安装完成" -ForegroundColor Green
} catch {
Write-Host " 安装失败: $($_.Exception.Message)" -ForegroundColor Red
Write-Host " 请手动运行: $installerPath" -ForegroundColor Yellow
exit 1
}
Write-Host ""
Write-Host "==> 清理临时文件..." -ForegroundColor Cyan
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "==> NSIS 安装完成!" -ForegroundColor Green
Write-Host ""
Write-Host "下一步操作:" -ForegroundColor Yellow
Write-Host " 1. 关闭当前 PowerShell 窗口" -ForegroundColor Cyan
Write-Host " 2. 重新打开 PowerShell让 PATH 环境变量生效)" -ForegroundColor Cyan
Write-Host " 3. 执行打包命令: .\打包.bat -Installer" -ForegroundColor Cyan
Write-Host ""
Write-Host "打包完成后,安装包会在 build\bin\ 目录,文件名带时间戳" -ForegroundColor Gray

View File

@@ -238,10 +238,15 @@ func shutdownHelperProgram() {
globalLogger.Info("使用Windows API成功终止辅助程序")
// 等待一小段时间确保进程完全终止
time.Sleep(300 * time.Millisecond)
}
}
// Windows 平台到此为止helperProcess 是用 &os.Process{Pid:...} 手工构造的,
// 没有有效的内部进程句柄。Go 1.25+ 对这种对象调用 Signal/Kill 会触发
// panic: handleTransientAcquire called in invalid mode进而导致主程序在退出
// 含正常关闭窗口时整体崩溃。Windows API 的 TerminateProcess 已是最终手段,
// 直接返回,绝不能跌落到下面的通用 Signal/Kill 分支。
return
}
}
}
// 通用方法:先尝试优雅地终止进程(发送终止信号)
err := helperProcess.Signal(syscall.SIGTERM)

209
scripts/build.ps1 Normal file
View File

@@ -0,0 +1,209 @@
<#
.SYNOPSIS
一键打包脚本。
.DESCRIPTION
默认产出「绿色免安装版」到 dist\qiweimanager\(不需要 NSIS解压即用
1. 定位 Go / Wails 工具链并注入 PATH。
2. 32 位编译 helper.exe编译 silk 音频解码器。
3. wails build 生成主程序 qiweimanager.exe前端一并编译
4. 汇总主程序 + helper + DLL + 工具 + 默认配置到 dist 目录。
指定 -Installer 时,额外调用现有的 scripts\package-windows.ps1 走 NSIS 生成安装包
(需本机已安装 NSIS
.PARAMETER Installer
额外生成 NSIS 安装包(委托 package-windows.ps1需要 makensis
.PARAMETER SkipTests
跳过 go test。
.PARAMETER GoPath / WailsPath
手动指定工具路径(自动探测失败时使用)。
.EXAMPLE
powershell -ExecutionPolicy Bypass -File scripts\build.ps1
powershell -ExecutionPolicy Bypass -File scripts\build.ps1 -Installer
#>
[CmdletBinding()]
param(
[switch]$Installer,
[switch]$SkipTests,
[string]$GoPath,
[string]$WailsPath
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path
Set-Location $repoRoot
function Write-Step([string]$msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok([string]$msg) { Write-Host " $msg" -ForegroundColor Green }
function Write-Warn2([string]$msg){ Write-Host " $msg" -ForegroundColor Yellow }
function Resolve-Go {
param([string]$Hint)
$candidates = @()
if ($Hint) { $candidates += $Hint }
$cmd = Get-Command go.exe -ErrorAction SilentlyContinue
if ($cmd) { $candidates += $cmd.Source }
$candidates += "$env:LOCALAPPDATA\Programs\Go\bin\go.exe", "C:\Program Files\Go\bin\go.exe", "C:\Go\bin\go.exe"
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } }
throw "未找到 Go。请安装 Go 1.24+ 后重试,或用 -GoPath 指定。"
}
function Resolve-Wails {
param([string]$Hint)
$candidates = @()
if ($Hint) { $candidates += $Hint }
$cmd = Get-Command wails.exe -ErrorAction SilentlyContinue
if ($cmd) { $candidates += $cmd.Source }
$candidates += "$env:USERPROFILE\go\bin\wails.exe"
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } }
throw "未找到 Wails CLI。请先运行 scripts\dev.ps1 安装,或用 -WailsPath 指定。"
}
function Copy-Required {
param([string]$Source, [string]$Destination)
if (-not (Test-Path -LiteralPath $Source)) { throw "缺少资源: $Source" }
$dir = Split-Path -Parent $Destination
if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
Copy-Item -LiteralPath $Source -Destination $Destination -Force
}
# ---- 委托 NSIS 安装包构建 ----
if ($Installer) {
Write-Step "委托 package-windows.ps1 生成 NSIS 安装包"
$pkg = Join-Path $scriptDir "package-windows.ps1"
$pkgArgs = @{}
if ($SkipTests) { $pkgArgs["SkipTests"] = $true }
if ($WailsPath) { $pkgArgs["WailsPath"] = $WailsPath }
& $pkg @pkgArgs
return
}
# ---- 工具链 ----
Write-Step "定位 Go / Wails 工具链"
$go = Resolve-Go -Hint $GoPath
$goBin = Split-Path -Parent $go
$env:PATH = "$goBin;$env:USERPROFILE\go\bin;$env:PATH"
$wails = Resolve-Wails -Hint $WailsPath
Write-Ok "Go: $go"
Write-Ok "Wails: $wails"
$goCache = Join-Path $repoRoot ".gocache"
New-Item -ItemType Directory -Force -Path $goCache | Out-Null
$env:GOCACHE = $goCache
$binDir = Join-Path $repoRoot "build\bin"
$distDir = Join-Path $repoRoot "dist\qiweimanager"
# ---- 测试 ----
if (-not $SkipTests) {
Write-Step "运行 Go 测试 (go test ./...)"
& $go test ./...
Write-Ok "测试通过"
}
# ---- 结束运行中的旧进程,避免覆盖失败 ----
$running = Get-Process -Name "qiweimanager", "qiweimanager-dev", "helper", "helper_auto_reply" -ErrorAction SilentlyContinue
foreach ($p in $running) { Write-Warn2 "结束运行中的进程: $($p.ProcessName) ($($p.Id))"; Stop-Process -Id $p.Id -Force }
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
# ---- 32 位 helper.exe ----
Write-Step "编译 32 位 helper.exe"
$helperOut = Join-Path $binDir "helper.exe"
$oldArch = $env:GOARCH
try {
$env:GOARCH = "386"
Push-Location (Join-Path $repoRoot "helper")
& $go build -trimpath -ldflags "-H windowsgui -s -w" -o $helperOut .
if ($LASTEXITCODE -ne 0) { throw "helper.exe 编译失败 (exit $LASTEXITCODE)" }
} finally {
Pop-Location
if ($null -eq $oldArch) { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue } else { $env:GOARCH = $oldArch }
}
Write-Ok "helper.exe 完成"
# ---- silk 音频解码器(尽力而为,需要 cgo/gcc缺失则跳过仅影响语音消息转码----
Write-Step "编译 silk 音频解码器"
$silkOut = Join-Path $binDir "tools\audio\silkdecode.exe"
$silkBuilt = $false
$hasGcc = [bool](Get-Command gcc -ErrorAction SilentlyContinue)
if (-not $hasGcc) {
Write-Warn2 "未检测到 gccsilkdecode 依赖 cgo 无法编译,跳过。语音消息自动转码功能在此构建中不可用。"
Write-Warn2 "如需启用:安装 MinGW-w64如 winget install -e --id mstorsjo.llvm-mingw 或 choco install mingw再重新打包。"
} else {
New-Item -ItemType Directory -Force -Path (Split-Path $silkOut) | Out-Null
Push-Location (Join-Path $repoRoot "tools\audio\silkdecode")
try {
$oldCgo = $env:CGO_ENABLED
$env:CGO_ENABLED = "1"
& $go build -trimpath -ldflags "-s -w" -o $silkOut .
if ($LASTEXITCODE -eq 0 -and (Test-Path $silkOut)) { $silkBuilt = $true; Write-Ok "silkdecode.exe 完成" }
else { Write-Warn2 "silkdecode 编译失败,已跳过(不影响主程序)。" }
} finally {
Pop-Location
if ($null -eq $oldCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $oldCgo }
}
}
# ---- 主程序 + 前端 ----
Write-Step "构建主程序 (wails build)"
& $wails build -trimpath -webview2 embed
if ($LASTEXITCODE -ne 0) { throw "wails build 失败 (exit $LASTEXITCODE)" }
$mainExe = Join-Path $binDir "qiweimanager.exe"
if (-not (Test-Path $mainExe)) { throw "主程序未生成: $mainExe" }
Write-Ok "qiweimanager.exe 完成"
# ---- 汇总绿色免安装版 ----
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$distDirWithTime = Join-Path $repoRoot "dist\qiweimanager_$timestamp"
Write-Step "汇总绿色免安装版到 dist\qiweimanager_$timestamp"
if (Test-Path $distDirWithTime) { Remove-Item $distDirWithTime -Recurse -Force }
New-Item -ItemType Directory -Force -Path $distDirWithTime | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $distDirWithTime "config\knowledge") | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $distDirWithTime "config\materials") | Out-Null
$mainExeWithTime = "qiweimanager_$timestamp.exe"
Copy-Required -Source $mainExe -Destination (Join-Path $distDirWithTime $mainExeWithTime)
Copy-Required -Source $helperOut -Destination (Join-Path $distDirWithTime "helper.exe")
if ($silkBuilt) {
Copy-Required -Source $silkOut -Destination (Join-Path $distDirWithTime "tools\audio\silkdecode.exe")
} else {
Write-Warn2 "dist 不含 silkdecode.exe语音转码功能不可用"
}
Copy-Required -Source (Join-Path $repoRoot "Helper_4.1.33.6009.dll") -Destination (Join-Path $distDirWithTime "Helper_4.1.33.6009.dll")
Copy-Required -Source (Join-Path $repoRoot "Loader_4.1.33.6009.dll") -Destination (Join-Path $distDirWithTime "Loader_4.1.33.6009.dll")
Copy-Item -LiteralPath (Join-Path $repoRoot "requestdata") -Destination (Join-Path $distDirWithTime "requestdata") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $repoRoot "eventdata") -Destination (Join-Path $distDirWithTime "eventdata") -Recurse -Force
# 默认配置(不含真实密钥)
$defaultConfig = @'
{
"callbackConfig": {
"callbackUrl": "",
"callbackToken": "",
"httpPort": "10001",
"enableCallback": false,
"enableCloudAuth": false,
"fileUploadUrl": "",
"deviceCode": ""
},
"lastUpdated": 0
}
'@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText((Join-Path $distDirWithTime "config\config.json"), $defaultConfig, $utf8NoBom)
[System.IO.File]::WriteAllText((Join-Path $distDirWithTime "config\client_status.json"), "{}", $utf8NoBom)
[System.IO.File]::WriteAllText((Join-Path $distDirWithTime "config\materials\materials.json"), '{"materials":[]}', $utf8NoBom)
Write-Host ""
Write-Step "打包完成"
Write-Ok "绿色免安装版: $distDirWithTime"
Write-Ok "主程序: $mainExeWithTime"
Write-Ok "直接进入该目录双击 $mainExeWithTime 即可运行。"
Write-Host " 如需 NSIS 安装包,请安装 NSIS 后运行: scripts\build.ps1 -Installer" -ForegroundColor DarkGray

213
scripts/dev.ps1 Normal file
View File

@@ -0,0 +1,213 @@
<#
.SYNOPSIS
一键本地开发启动脚本。
.DESCRIPTION
自动完成以下工作,然后运行 `wails dev`
1. 定位 Go 与 Wails CLI即使未写入 PATH 也能找到常见安装位置)。
2. 把 Go / Wails 注入当前会话 PATH并设置 GOCACHE 到仓库内的 .gocache。
3. 首次运行时自动安装前端依赖frontend/node_modules与 Go 模块。
4. 校验企业微信调试所需的 helper.exe 与 DLL 是否在运行目录。
5. 启动 wails dev前端热更新 + Go 热重载)。
.PARAMETER SkipInstall
跳过依赖安装检查,直接启动(依赖已装好时更快)。
.PARAMETER GoPath
手动指定 go.exe 路径(自动探测失败时使用)。
.PARAMETER WailsPath
手动指定 wails.exe 路径(自动探测失败时使用)。
.EXAMPLE
powershell -ExecutionPolicy Bypass -File scripts\dev.ps1
#>
[CmdletBinding()]
param(
[switch]$SkipInstall,
[string]$GoPath,
[string]$WailsPath
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path
Set-Location $repoRoot
function Write-Step([string]$msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok([string]$msg) { Write-Host " $msg" -ForegroundColor Green }
function Write-Warn2([string]$msg){ Write-Host " $msg" -ForegroundColor Yellow }
# ---- 定位 Go ----
function Resolve-Go {
param([string]$Hint)
$candidates = @()
if ($Hint) { $candidates += $Hint }
$cmd = Get-Command go.exe -ErrorAction SilentlyContinue
if ($cmd) { $candidates += $cmd.Source }
$candidates += "$env:LOCALAPPDATA\Programs\Go\bin\go.exe"
$candidates += "C:\Program Files\Go\bin\go.exe"
$candidates += "C:\Go\bin\go.exe"
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } }
throw "未找到 Go。请安装 Go 1.24+ 后重试,或用 -GoPath 指定 go.exe。"
}
# ---- 定位 Wails CLI ----
function Resolve-Wails {
param([string]$Hint)
$candidates = @()
if ($Hint) { $candidates += $Hint }
$cmd = Get-Command wails.exe -ErrorAction SilentlyContinue
if ($cmd) { $candidates += $cmd.Source }
$candidates += "$env:USERPROFILE\go\bin\wails.exe"
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } }
return $null
}
# ---- 环境准备 ----
Write-Step "定位 Go / Wails 工具链"
$go = Resolve-Go -Hint $GoPath
$goBin = Split-Path -Parent $go
$goPathBin = Join-Path $env:USERPROFILE "go\bin"
$env:PATH = "$goBin;$goPathBin;$env:PATH"
Write-Ok "Go: $go"
$wails = Resolve-Wails -Hint $WailsPath
if (-not $wails) {
Write-Warn2 "未找到 Wails CLI正在安装go install..."
& $go install github.com/wailsapp/wails/v2/cmd/wails@latest
$wails = Resolve-Wails
if (-not $wails) { throw "Wails CLI 安装失败,请检查网络或 GOPROXY。" }
}
Write-Ok "Wails: $wails"
# GOCACHE 放仓库内,避免污染系统缓存
$goCache = Join-Path $repoRoot ".gocache"
New-Item -ItemType Directory -Force -Path $goCache | Out-Null
$env:GOCACHE = $goCache
# ---- 依赖安装 ----
if (-not $SkipInstall) {
$nodeModules = Join-Path $repoRoot "frontend\node_modules"
if (-not (Test-Path $nodeModules)) {
Write-Step "安装前端依赖 (npm install)"
Push-Location (Join-Path $repoRoot "frontend")
try { & npm install } finally { Pop-Location }
Write-Ok "前端依赖安装完成"
} else {
Write-Ok "前端依赖已存在,跳过 npm install"
}
Write-Step "同步 Go 模块 (go mod download)"
& $go mod download
Write-Ok "Go 模块就绪"
}
# ---- 编译 32 位 helper.exe统一用正确链接参数杜绝手动编错导致主程序拉起即崩----
# helper 必须 GOARCH=386 且带 -H windowsgui主程序是 GUI 子系统,用 CreateProcess 拉起 helper 时
# 不分配控制台;若 helper 误编成默认 console 子系统(漏掉 -H windowsgui被主程序拉起后会因标准
# 句柄处理立即崩溃——表现为主程序日志「辅助程序已成功启动 PID=xxxx」但该 PID 秒退、不写日志、
# 10001 端口无人监听、前端“启动企微”无反应、满屏 dial tcp [::1]:10001 拒绝连接。诡异之处在于
# console 版 helper 在终端手动运行一切正常,只有被主程序拉起才崩,极易误判为二进制本身没问题。
# 这里每次 dev 启动都用与 scripts\build.ps1 完全一致的参数重编 helper既保证 helper 源码改动生效,
# 又把“唯一正确的编译方式”固化进脚本,从源头消除编错风险。
Write-Step "编译 32 位 helper.exe"
$binDir = Join-Path $repoRoot "build\bin"
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
$helperOut = Join-Path $binDir "helper.exe"
# 先停掉可能占用 helper.exe 的残留进程,否则无法覆盖输出文件。
Get-Process -Name "helper", "helper_auto_reply" -ErrorAction SilentlyContinue | ForEach-Object {
Write-Warn2 "结束残留 helper 进程 (PID $($_.Id))"
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
}
$oldArch = $env:GOARCH
$oldCgo = $env:CGO_ENABLED
try {
$env:GOARCH = "386"
$env:CGO_ENABLED = "0"
Push-Location (Join-Path $repoRoot "helper")
& $go build -trimpath -ldflags "-H windowsgui -s -w" -o $helperOut .
$helperExit = $LASTEXITCODE
} finally {
Pop-Location
if ($null -eq $oldArch) { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue } else { $env:GOARCH = $oldArch }
if ($null -eq $oldCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $oldCgo }
}
if ($helperExit -ne 0) { throw "helper.exe 编译失败 (exit $helperExit)。企业微信链路将不可用,请检查 helper 目录源码。" }
# 子系统自检windowsgui 版的 PE 头 Subsystem 字段为 2GUIconsole 版为 3。若意外编出 console 版直接拦下。
try {
$bytes = [System.IO.File]::ReadAllBytes($helperOut)
$peOff = [System.BitConverter]::ToInt32($bytes, 0x3C) # e_lfanew → PE 头偏移
$subsystem = [System.BitConverter]::ToUInt16($bytes, $peOff + 0x5C) # OptionalHeader.Subsystem
if ($subsystem -eq 2) {
Write-Ok "helper.exe 已编译 (GOARCH=386, GUI 子系统,可被主程序安全拉起)"
} else {
throw "helper.exe 子系统为 $subsystem(期望 2=GUI。这会导致主程序拉起即崩请确认编译参数含 -H windowsgui。"
}
} catch [System.Management.Automation.RuntimeException] {
throw
} catch {
Write-Warn2 "helper.exe 子系统自检跳过:$($_.Exception.Message)"
}
$wxDlls = @("Helper_4.1.33.6009.dll", "Loader_4.1.33.6009.dll")
foreach ($dll in $wxDlls) {
if (Test-Path (Join-Path $repoRoot $dll)) { Write-Ok "$dll 已就绪" } else { Write-Warn2 "缺少 $dll,企业微信链路可能无法工作" }
}
# wails dev 把主程序与 helper 跑在 build\bin 下helper 需要在自身目录找到这两个 DLL。
# 仓库根目录有 DLL 但 build\bin 没有 → "启动企微"会失败并提示 No Helper_*.dll found。
# 这里把根目录 DLL 同步到 build\binbuild\bin 由 wails 在编译时创建,缺失则跳过,启动后会自动建好;
# 若本次启动后仍报缺 DLL再次运行本脚本即可补齐
$binDir = Join-Path $repoRoot "build\bin"
if (Test-Path $binDir) {
foreach ($dll in $wxDlls) {
$src = Join-Path $repoRoot $dll
if (-not (Test-Path $src)) { continue }
$dst = Join-Path $binDir $dll
# 目标已是同样大小则无需复制(避免覆盖正被 helper 占用的 DLL 而报错)
if ((Test-Path $dst) -and ((Get-Item $dst).Length -eq (Get-Item $src).Length)) {
Write-Ok "$dll 已在 build\bin无需同步"
continue
}
try {
Copy-Item -LiteralPath $src -Destination $dst -Force
Write-Ok "已同步 $dll 到 build\bin"
} catch {
if (Test-Path $dst) {
Write-Warn2 "$dll 正被占用无法更新,但 build\bin 已有可用副本,继续。"
} else {
Write-Warn2 "同步 $dll 到 build\bin 失败且目标缺失:$($_.Exception.Message)。请先结束残留 helper.exe 再重试。"
}
}
}
} else {
Write-Warn2 "build\bin 尚不存在(首次编译后才会创建)。若启动后“启动企微”提示缺少 DLL请重新运行本脚本以同步。"
}
# helper 运行时要在自身目录build\bin下按消息类型号读取 eventdata\<type>.json 模板来翻译企微事件。
# 若 build\bin 缺少 eventdataTransformData 会走兜底分支并丢掉顶层 type 字段,导致文本消息被误判成图片、
# 触发图片识别失败并反复回复“无法识别这条图片/视频内容”。requestdata 同理为运行时资源。
# 这里每次 dev 启动都把仓库根目录的 eventdata/requestdata 同步到 build\bin从源头杜绝该问题复发。
if (Test-Path $binDir) {
foreach ($resource in @("eventdata", "requestdata")) {
$src = Join-Path $repoRoot $resource
if (-not (Test-Path $src)) { Write-Warn2 "仓库缺少 $resource 目录,跳过同步"; continue }
$dst = Join-Path $binDir $resource
try {
Copy-Item -LiteralPath $src -Destination $binDir -Recurse -Force
Write-Ok "已同步 $resource 到 build\bin"
} catch {
Write-Warn2 "同步 $resource 到 build\bin 失败:$($_.Exception.Message)"
}
}
}
# ---- 启动 ----
Write-Step "启动 wails devCtrl+C 退出)"
Write-Host ""
& $wails dev

View File

@@ -112,7 +112,20 @@ $env:GOCACHE = (Resolve-Path (Join-Path $repoRoot ".gocache")).Path
$wailsFallback = if ($WailsPath) { $WailsPath } else { Join-Path $env:USERPROFILE "go\bin\wails.exe" }
$wails = Resolve-RequiredTool -Name "wails.exe" -FallbackPath $wailsFallback -InstallHint "Install Wails CLI on the build machine first."
$makensis = Resolve-RequiredTool -Name "makensis.exe" -FallbackPath $MakensisPath -InstallHint "Install NSIS on the build machine first, e.g. run as Administrator: choco install nsis -y; or pass -MakensisPath."
# 自动探测 NSIS 标准安装路径(未在 PATH 时也能找到),优先使用显式传入的 -MakensisPath
$makensisFallback = $null
$makensisCandidates = @()
if ($MakensisPath) { $makensisCandidates += $MakensisPath }
$makensisCandidates += @(
(Join-Path ${env:ProgramFiles(x86)} "NSIS\makensis.exe"),
(Join-Path $env:ProgramFiles "NSIS\makensis.exe"),
(Join-Path $env:LOCALAPPDATA "Programs\NSIS\makensis.exe")
)
foreach ($candidate in $makensisCandidates) {
if ($candidate -and (Test-Path -LiteralPath $candidate)) { $makensisFallback = $candidate; break }
}
$makensis = Resolve-RequiredTool -Name "makensis.exe" -FallbackPath $makensisFallback -InstallHint "Install NSIS on the build machine first, e.g. run as Administrator: choco install nsis -y; or pass -MakensisPath."
$npm = Resolve-RequiredTool -Name "npm.cmd" -InstallHint "Install Node.js on the build machine first."
$go = Resolve-RequiredTool -Name "go.exe" -InstallHint "Install Go on the build machine first."
$pdftoppm = Get-Command "pdftoppm.exe" -ErrorAction SilentlyContinue
@@ -149,12 +162,20 @@ try {
Write-Host "==> Building bundled silk decoder"
$silkDecoderOut = Join-Path $binDir "tools\audio\silkdecode.exe"
$silkBuilt = $false
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
Write-Warning "gcc not found; silkdecode requires cgo and will be skipped. Voice message transcoding will be unavailable."
} else {
New-Item -ItemType Directory -Force -Path (Split-Path $silkDecoderOut) | Out-Null
Push-Location (Join-Path $repoRoot "tools\audio\silkdecode")
try {
$oldCgo = $env:CGO_ENABLED; $env:CGO_ENABLED = "1"
& $go build -trimpath -ldflags "-s -w" -o $silkDecoderOut .
if ($LASTEXITCODE -eq 0) { $silkBuilt = $true } else { Write-Warning "silkdecode build failed, skipping." }
} finally {
Pop-Location
if ($null -eq $oldCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $oldCgo }
}
}
if ($pdftoppm) {
@@ -185,7 +206,11 @@ if (-not $SkipFrontendBuild) {
Write-Host "==> Building frontend"
Push-Location (Join-Path $repoRoot "frontend")
try {
& $npm run build
# 原生命令往 stderr 写警告会在 Stop 策略下被误判为失败,这里改按退出码判断
$oldEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
& $npm run build 2>&1 | ForEach-Object { "$_" }
$ErrorActionPreference = $oldEap
if ($LASTEXITCODE -ne 0) { throw "frontend build failed (exit $LASTEXITCODE)" }
} finally {
Pop-Location
}
@@ -197,7 +222,11 @@ New-Item -ItemType Directory -Force -Path (Join-Path $runtimeDir "config\knowled
New-Item -ItemType Directory -Force -Path (Join-Path $runtimeDir "config\materials") | Out-Null
Copy-RequiredFile -Source $helperOut -Destination (Join-Path $runtimeDir "helper.exe")
if ($silkBuilt -and (Test-Path -LiteralPath $silkDecoderOut)) {
Copy-RequiredFile -Source $silkDecoderOut -Destination (Join-Path $runtimeDir "tools\audio\silkdecode.exe")
} else {
Write-Warning "silkdecode.exe not bundled (gcc unavailable); voice transcoding disabled in this installer."
}
if (Test-Path -LiteralPath (Join-Path $binDir "tools\pdf\pdftoppm.exe")) {
Copy-RequiredFile -Source (Join-Path $binDir "tools\pdf\pdftoppm.exe") -Destination (Join-Path $runtimeDir "tools\pdf\pdftoppm.exe")
}
@@ -239,11 +268,17 @@ if (Test-Path -LiteralPath $materialsIndex) {
}
Write-Host "==> Building Wails NSIS installer"
& $wails build --nsis -webview2 embed -trimpath
$oldEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
& $wails build --nsis -webview2 embed -trimpath 2>&1 | ForEach-Object { "$_" }
$ErrorActionPreference = $oldEap
$installer = Join-Path $binDir "qiweimanager-amd64-installer.exe"
if (-not (Test-Path -LiteralPath $installer)) {
throw "Installer was not generated: $installer"
}
Write-Host "==> Release complete: $installer"
# 添加时间戳到安装包文件名
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$installerWithTime = Join-Path $binDir "qiweimanager-amd64-installer_$timestamp.exe"
Move-Item -LiteralPath $installer -Destination $installerWithTime -Force
Write-Host "==> Release complete: $installerWithTime"

6
启动开发.bat Normal file
View File

@@ -0,0 +1,6 @@
@echo off
chcp 65001 >nul
rem 一键本地开发:自动配置 Go/Wails 环境、安装依赖并启动 wails dev
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\dev.ps1" %*
pause

7
打包.bat Normal file
View File

@@ -0,0 +1,7 @@
@echo off
chcp 65001 >nul
rem 一键打包:编译 helper + 主程序,产出 dist\qiweimanager 绿色免安装版
rem 如需 NSIS 安装包,改为运行: 打包.bat -Installer (需先安装 NSIS
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build.ps1" %*
pause