From f7f323462cdb22d03fca55666d2c5e0741367102 Mon Sep 17 00:00:00 2001 From: gr Date: Tue, 3 Nov 2026 14:10:56 +0800 Subject: [PATCH] fix(admin): restore review workspace and add user account editing --- admin-web/apps/web-antd/package.json | 1 + admin-web/apps/web-antd/src/api/archive.ts | 1 + admin-web/apps/web-antd/src/api/console.ts | 111 ++- admin-web/apps/web-antd/src/api/core/auth.ts | 3 +- .../apps/web-antd/src/api/error-message.ts | 23 +- admin-web/apps/web-antd/src/api/knowledge.ts | 154 +++- admin-web/apps/web-antd/src/api/request.ts | 3 +- admin-web/apps/web-antd/src/bootstrap.ts | 22 +- .../components/archive-account-selector.vue | 7 +- admin-web/apps/web-antd/src/layouts/basic.vue | 166 +--- .../apps/web-antd/src/locales/en/archive.ts | 255 ++++++ .../apps/web-antd/src/locales/en/common.ts | 27 + .../apps/web-antd/src/locales/en/desktop.ts | 241 +++++ .../apps/web-antd/src/locales/en/knowledge.ts | 348 +++++++ .../apps/web-antd/src/locales/en/menu.ts | 36 + .../apps/web-antd/src/locales/en/models.ts | 265 ++++++ .../web-antd/src/locales/en/permissions.ts | 250 +++++ .../apps/web-antd/src/locales/en/users.ts | 41 + .../src/locales/i18n-coverage.test.ts | 80 ++ admin-web/apps/web-antd/src/locales/index.ts | 6 + admin-web/apps/web-antd/src/locales/t.ts | 31 + admin-web/apps/web-antd/src/main.ts | 5 +- admin-web/apps/web-antd/src/preferences.ts | 6 +- admin-web/apps/web-antd/src/router/access.ts | 19 +- .../web-antd/src/router/custom-menus.test.ts | 63 ++ .../apps/web-antd/src/router/custom-menus.ts | 83 ++ admin-web/apps/web-antd/src/router/guard.ts | 12 +- .../web-antd/src/router/menu-access.test.ts | 32 + .../apps/web-antd/src/router/menu-access.ts | 41 + .../src/router/routes/modules/console.ts | 24 +- admin-web/apps/web-antd/src/store/auth.ts | 3 +- .../apps/web-antd/src/styles/layout-fixes.css | 17 + .../apps/web-antd/src/utils/file-sha256.ts | 25 + .../apps/web-antd/src/utils/group-sso.ts | 19 +- .../_core/authentication/group-login.vue | 9 +- .../src/views/_core/authentication/login.vue | 19 +- .../src/views/_core/profile/base-setting.vue | 20 +- .../src/views/_core/profile/index.vue | 17 +- .../_core/profile/notification-setting.vue | 14 +- .../views/_core/profile/password-setting.vue | 22 +- .../views/_core/profile/security-setting.vue | 22 +- .../src/views/archive/conversations.vue | 78 +- .../web-antd/src/views/archive/exports.vue | 51 +- .../web-antd/src/views/archive/overview.vue | 37 +- .../web-antd/src/views/archive/people.vue | 148 +-- .../web-antd/src/views/archive/storage.vue | 71 +- .../src/views/console/ModelUsageStats.vue | 98 +- .../web-antd/src/views/console/agents.vue | 197 ++-- .../apps/web-antd/src/views/console/audit.vue | 20 +- .../web-antd/src/views/console/call-stats.vue | 100 +- .../src/views/console/change-password.vue | 27 +- .../src/views/console/desktop-accounts.vue | 103 ++- .../src/views/console/desktop-config.vue | 164 ++-- .../src/views/console/model-catalog.vue | 151 ++- .../web-antd/src/views/console/model-plan.vue | 58 +- .../src/views/console/model-usage-format.ts | 6 +- .../src/views/console/permission-menu.test.ts | 163 ++++ .../src/views/console/permission-menu.ts | 260 ++++++ .../src/views/console/permissions.vue | 336 +++++++ .../web-antd/src/views/console/release.vue | 151 +-- .../apps/web-antd/src/views/console/roles.vue | 199 ++-- .../src/views/console/use-usage-request.ts | 4 +- .../apps/web-antd/src/views/console/users.vue | 167 ++-- .../apps/web-antd/src/views/group/welcome.vue | 13 +- .../src/views/knowledge/batch-dialog.vue | 47 +- .../web-antd/src/views/knowledge/batch.ts | 8 +- .../web-antd/src/views/knowledge/center.vue | 279 +++--- .../src/views/knowledge/job-config.vue | 37 - .../src/views/knowledge/job-wizard.vue | 70 -- .../src/views/knowledge/model-fields.vue | 43 - .../src/views/knowledge/review-all.vue | 80 +- .../src/views/knowledge/run-dialog.vue | 163 ++++ .../src/views/knowledge/run-format.test.ts | 61 ++ .../src/views/knowledge/run-format.ts | 135 +++ .../src/views/knowledge/runs-panel.vue | 180 ++++ .../src/views/knowledge/settings-dialog.vue | 73 ++ .../src/views/knowledge/wash-options.vue | 120 +++ .../apps/web-antd/tests/group-sso.test.mjs | 1 + admin-web/pnpm-lock.yaml | 865 ++++++------------ wechat_rpa/admin_api.py | 4 +- wechat_rpa/admin_backend.py | 19 +- wechat_rpa/test_admin_api.py | 46 + 82 files changed, 5281 insertions(+), 2095 deletions(-) create mode 100644 admin-web/apps/web-antd/src/locales/en/archive.ts create mode 100644 admin-web/apps/web-antd/src/locales/en/common.ts create mode 100644 admin-web/apps/web-antd/src/locales/en/desktop.ts create mode 100644 admin-web/apps/web-antd/src/locales/en/knowledge.ts create mode 100644 admin-web/apps/web-antd/src/locales/en/menu.ts create mode 100644 admin-web/apps/web-antd/src/locales/en/models.ts create mode 100644 admin-web/apps/web-antd/src/locales/en/permissions.ts create mode 100644 admin-web/apps/web-antd/src/locales/en/users.ts create mode 100644 admin-web/apps/web-antd/src/locales/i18n-coverage.test.ts create mode 100644 admin-web/apps/web-antd/src/locales/t.ts create mode 100644 admin-web/apps/web-antd/src/router/custom-menus.test.ts create mode 100644 admin-web/apps/web-antd/src/router/custom-menus.ts create mode 100644 admin-web/apps/web-antd/src/router/menu-access.test.ts create mode 100644 admin-web/apps/web-antd/src/router/menu-access.ts create mode 100644 admin-web/apps/web-antd/src/styles/layout-fixes.css create mode 100644 admin-web/apps/web-antd/src/utils/file-sha256.ts create mode 100644 admin-web/apps/web-antd/src/views/console/permission-menu.test.ts create mode 100644 admin-web/apps/web-antd/src/views/console/permission-menu.ts create mode 100644 admin-web/apps/web-antd/src/views/console/permissions.vue delete mode 100644 admin-web/apps/web-antd/src/views/knowledge/job-config.vue delete mode 100644 admin-web/apps/web-antd/src/views/knowledge/job-wizard.vue delete mode 100644 admin-web/apps/web-antd/src/views/knowledge/model-fields.vue create mode 100644 admin-web/apps/web-antd/src/views/knowledge/run-dialog.vue create mode 100644 admin-web/apps/web-antd/src/views/knowledge/run-format.test.ts create mode 100644 admin-web/apps/web-antd/src/views/knowledge/run-format.ts create mode 100644 admin-web/apps/web-antd/src/views/knowledge/runs-panel.vue create mode 100644 admin-web/apps/web-antd/src/views/knowledge/settings-dialog.vue create mode 100644 admin-web/apps/web-antd/src/views/knowledge/wash-options.vue diff --git a/admin-web/apps/web-antd/package.json b/admin-web/apps/web-antd/package.json index 6e301e653..7aa38bb63 100644 --- a/admin-web/apps/web-antd/package.json +++ b/admin-web/apps/web-antd/package.json @@ -43,6 +43,7 @@ "@vueuse/core": "catalog:", "ant-design-vue": "catalog:", "dayjs": "catalog:", + "hash-wasm": "4.12.0", "pinia": "catalog:", "vue": "catalog:", "vue-router": "catalog:" diff --git a/admin-web/apps/web-antd/src/api/archive.ts b/admin-web/apps/web-antd/src/api/archive.ts index 44724bdb8..9864cf149 100644 --- a/admin-web/apps/web-antd/src/api/archive.ts +++ b/admin-web/apps/web-antd/src/api/archive.ts @@ -29,6 +29,7 @@ export interface ArchiveMessage { attachments: ArchiveAttachment[]; content: string; direction: string; + message_origin?: 'ai' | 'human' | 'system' | 'unknown' | 'customer'; id: string; message_type: string; sender_name: string; diff --git a/admin-web/apps/web-antd/src/api/console.ts b/admin-web/apps/web-antd/src/api/console.ts index edba88d15..31da8f5c5 100644 --- a/admin-web/apps/web-antd/src/api/console.ts +++ b/admin-web/apps/web-antd/src/api/console.ts @@ -5,6 +5,8 @@ * 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。 */ import { requestClient } from '#/api/request'; +import { t } from '#/locales/t'; +import { sha256File } from '#/utils/file-sha256'; /** * 账号下拉框里「全部」的取值,和后端 `admin_api.ALL_ACCOUNTS` 是同一个约定。 @@ -53,10 +55,54 @@ export interface RoleItem { user_count: number; } +/** + * 权限菜单里的一条:一级菜单(group_name)→ 页面(page_name)→ 权限码。 + * + * - source = builtin:随代码发布,只读 + * - source = custom:管理员在「权限菜单」页自己加的,可改可删 + * - source = discovered:代码里用到却没登记,后端启动时自动登记到「未归类」 + */ export interface PermissionItem { code: string; + enabled: boolean; group_name: string; + icon: string; + /** page = 能打开页面;action = 页面里的操作;link = 左侧菜单链接 */ + kind: 'action' | 'link' | 'page'; + link_mode: '' | 'iframe' | 'tab'; + link_url: string; name: string; + page_name: string; + /** 持有这个权限码的角色 */ + roles: string[]; + sort_order: number; + source: 'builtin' | 'custom' | 'discovered'; + updated_at: string; +} + +/** 新增/修改自定义条目时提交的字段 */ +export interface PermissionForm { + code: string; + enabled: boolean; + group_name: string; + icon: string; + kind: 'action' | 'link'; + link_mode: 'iframe' | 'tab'; + link_url: string; + name: string; + page_name: string; + sort_order: number; +} + +/** 左侧菜单里管理员自己加的链接(后端已按当前用户的权限过滤) */ +export interface CustomMenuItem { + code: string; + group_name: string; + icon: string; + link_mode: '' | 'iframe' | 'tab'; + link_url: string; + name: string; + sort_order: number; } export interface UserItem { @@ -243,6 +289,30 @@ export function fetchPermissions() { return requestClient.get<{ permissions: PermissionItem[] }>('/permissions'); } +export function createPermission(data: PermissionForm) { + return requestClient.post<{ permissions: PermissionItem[] }>( + '/permissions', + data, + ); +} + +export function updatePermission(code: string, data: PermissionForm) { + return requestClient.put<{ permissions: PermissionItem[] }>( + `/permissions/${encodeURIComponent(code)}`, + data, + ); +} + +export function deletePermission(code: string) { + return requestClient.delete<{ permissions: PermissionItem[] }>( + `/permissions/${encodeURIComponent(code)}`, + ); +} + +export function fetchCustomMenus() { + return requestClient.get<{ menus: CustomMenuItem[] }>('/custom-menus'); +} + export function saveRole(data: { code: string; name: string; @@ -272,7 +342,7 @@ export function createUser(data: { export function updateUser( id: number, - data: { active: boolean; role: string }, + data: { active: boolean; role: string; username?: string; password?: string }, ) { return requestClient.put<{ users: UserItem[] }>(`/users/${id}`, data); } @@ -440,6 +510,7 @@ export function saveConfig(data: DesktopConfig) { // ── 桌面端版本升级 ─────────────────────────────────────────────────────────── export interface ReleasePolicy { + package_media_id?: string; auto_install: boolean; download_url: string; force_upgrade: boolean; @@ -456,6 +527,8 @@ export interface ReleasePolicy { } export interface ReleasePackage { + package_media_id: string; + download_url_expires_in: number; download_url: string; file_name: string; package_sha256: string; @@ -500,6 +573,7 @@ export function fetchRelease() { } export function saveRelease(data: { + package_media_id?: string; auto_install: boolean; download_url: string; force_upgrade: boolean; @@ -515,19 +589,6 @@ export function saveRelease(data: { return requestClient.post('/release', data); } -async function sha256File(file: File) { - if (!globalThis.crypto?.subtle) { - throw new Error('当前浏览器不支持安全哈希,请使用 HTTPS 或本机地址打开管理端'); - } - const digest = await globalThis.crypto.subtle.digest( - 'SHA-256', - await file.arrayBuffer(), - ); - return [...new Uint8Array(digest)] - .map((value) => value.toString(16).padStart(2, '0')) - .join(''); -} - function putCosBlob( url: string, body: Blob, @@ -542,14 +603,14 @@ function putCosBlob( xhr.setRequestHeader(key, value); }); xhr.upload.onprogress = (event) => onProgress?.(event.loaded); - xhr.onerror = () => reject(new Error('无法连接腾讯 COS,请检查网络和 Bucket CORS 配置')); - xhr.ontimeout = () => reject(new Error('上传腾讯 COS 超时,请重试')); + xhr.onerror = () => reject(new Error(t('无法连接腾讯 COS,请检查网络和 Bucket CORS 配置'))); + xhr.ontimeout = () => reject(new Error(t('上传腾讯 COS 超时,请重试'))); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve((xhr.getResponseHeader('ETag') || '').replaceAll('"', '')); return; } - reject(new Error(`腾讯 COS 上传失败(HTTP ${xhr.status})`)); + reject(new Error(t('腾讯 COS 上传失败(HTTP {status})', { status: xhr.status }))); }; xhr.send(body); }); @@ -562,7 +623,7 @@ async function uploadMultipartPackage( ) { const multipart = prepared.multipart; if (!multipart?.upload_id || !multipart.parts.length) { - throw new Error('COS 分块上传参数不完整'); + throw new Error(t('COS 分块上传参数不完整')); } const partSize = multipart.part_size; const uploaded = new Map(); @@ -605,7 +666,7 @@ async function uploadMultipartPackage( } if (lastError) throw lastError; if (!etag) { - throw new Error('COS 未返回 ETag,请在 Bucket CORS 中暴露 ETag 响应头'); + throw new Error(t('COS 未返回 ETag,请在 Bucket CORS 中暴露 ETag 响应头')); } uploaded.set(part.part_number, part.size_bytes); completed.set(part.part_number, etag); @@ -633,13 +694,15 @@ export async function uploadReleasePackage( onProgress?: (percent: number) => void, ) { if (!file.name.toLowerCase().endsWith('.exe')) { - throw new Error('升级包必须是 Windows EXE 安装包'); + throw new Error(t('升级包必须是 Windows EXE 安装包')); } if (file.size <= 0 || file.size > 1024 ** 3) { - throw new Error('安装包大小必须大于 0 且不能超过 1GB'); + throw new Error(t('安装包大小必须大于 0 且不能超过 1GB')); } onProgress?.(1); - const packageSha256 = await sha256File(file); + const packageSha256 = await sha256File(file, (fraction) => { + onProgress?.(1 + Math.floor(fraction * 4)); + }); onProgress?.(5); const prepared = await requestClient.post( '/release/package/prepare', @@ -658,7 +721,7 @@ export async function uploadReleasePackage( onProgress?.(100); return result; } - if (!prepared.upload_url) throw new Error('COS 预上传接口没有返回上传地址'); + if (!prepared.upload_url) throw new Error(t('COS 预上传接口没有返回上传地址')); await putCosBlob( prepared.upload_url, file, @@ -715,8 +778,6 @@ export function testModel(data: { ); } - -// 每次实际上游请求的计量,包含所有候选、裁判和重试;不复用最终回答日志的条数。 export interface ModelUsageSummary { request_count: number; known_count: number; diff --git a/admin-web/apps/web-antd/src/api/core/auth.ts b/admin-web/apps/web-antd/src/api/core/auth.ts index 81ed53d8c..b13538c35 100644 --- a/admin-web/apps/web-antd/src/api/core/auth.ts +++ b/admin-web/apps/web-antd/src/api/core/auth.ts @@ -1,6 +1,7 @@ import type { GroupConfig } from '#/utils/group-sso'; import { baseRequestClient, requestClient } from '#/api/request'; +import { t } from '#/locales/t'; /** * 认证接口。 @@ -62,7 +63,7 @@ export async function groupExchangeApi(data: { code: string; code_verifier: stri * 重试,表现是页面卡死而不是跳登录页。 */ export async function refreshTokenApi(): Promise { - throw new Error('后端未启用令牌续期,请重新登录'); + throw new Error(t('后端未启用令牌续期,请重新登录')); } export async function logoutApi() { diff --git a/admin-web/apps/web-antd/src/api/error-message.ts b/admin-web/apps/web-antd/src/api/error-message.ts index 28a6a5e77..8d7278320 100644 --- a/admin-web/apps/web-antd/src/api/error-message.ts +++ b/admin-web/apps/web-antd/src/api/error-message.ts @@ -1,19 +1,22 @@ +import { t } from '#/locales/t'; + /** Convert API errors to text without rendering objects or echoing request inputs. */ function errorText(value: unknown, depth = 0): string { - if (typeof value === 'string') return value.trim(); + // Backend messages are shown as-is; t() only swaps in an English text when the dictionary has one. + if (typeof value === 'string') return t(value.trim()); if (!value || typeof value !== 'object' || depth > 3) return ''; if (Array.isArray(value)) { - return [...new Set(value.slice(0, 3).map((entry) => errorText(entry, depth + 1)).filter(Boolean))].join(';'); + return [...new Set(value.slice(0, 3).map((entry) => errorText(entry, depth + 1)).filter(Boolean))].join(t(';')); } const detail = value as Record; if (Array.isArray(detail.loc) && typeof detail.type === 'string') { - const field = detail.loc.at(-1) === 'query' ? '检索问题' : '请求参数'; + const field = detail.loc.at(-1) === 'query' ? t('检索问题') : t('请求参数'); const ctx = detail.ctx as Record | undefined; - if (detail.type === 'string_too_short' && typeof ctx?.min_length === 'number') return `${field}至少需要 ${ctx.min_length} 个字符`; - if (detail.type === 'string_too_long' && typeof ctx?.max_length === 'number') return `${field}不能超过 ${ctx.max_length} 个字符`; - if (detail.type === 'missing') return `请填写${field}`; - if (detail.type === 'string_type') return `${field}必须是文本`; - return typeof detail.msg === 'string' ? `${field}:${detail.msg}` : `${field}格式不正确`; + if (detail.type === 'string_too_short' && typeof ctx?.min_length === 'number') return t('{field}至少需要 {min} 个字符', { field, min: ctx.min_length }); + if (detail.type === 'string_too_long' && typeof ctx?.max_length === 'number') return t('{field}不能超过 {max} 个字符', { field, max: ctx.max_length }); + if (detail.type === 'missing') return t('请填写{field}', { field }); + if (detail.type === 'string_type') return t('{field}必须是文本', { field }); + return typeof detail.msg === 'string' ? t('{field}:{msg}', { field, msg: detail.msg }) : t('{field}格式不正确', { field }); } for (const key of ['detail', 'error', 'message', 'msg']) { const text = errorText(detail[key], depth + 1); @@ -22,11 +25,11 @@ function errorText(value: unknown, depth = 0): string { return ''; } -export function responseErrorMessage(data: unknown, fallback = '请求失败,请稍后重试'): string { +export function responseErrorMessage(data: unknown, fallback = t('请求失败,请稍后重试')): string { return errorText(data) || fallback; } -export function requestErrorMessage(error: unknown, fallback = '请求失败,请稍后重试'): string { +export function requestErrorMessage(error: unknown, fallback = t('请求失败,请稍后重试')): string { const data = (error as { response?: { data?: unknown } } | null)?.response?.data; // RequestClient unwraps Axios response.data before rejecting to page handlers. return responseErrorMessage(data ?? (error instanceof Error ? undefined : error), fallback); diff --git a/admin-web/apps/web-antd/src/api/knowledge.ts b/admin-web/apps/web-antd/src/api/knowledge.ts index c5bb4e1f7..3bf73f594 100644 --- a/admin-web/apps/web-antd/src/api/knowledge.ts +++ b/admin-web/apps/web-antd/src/api/knowledge.ts @@ -3,6 +3,9 @@ import { requestClient } from '#/api/request'; export interface KnowledgeItem { id: string; tenant_id: string; + /** 所属客户端账号(「全部账号」下列表混着多个账号时用来区分) */ + account_id?: number; + account_label?: string; title: string; question: string; answer: string; @@ -21,35 +24,6 @@ export interface KnowledgeItem { export interface KnowledgeModel { id: string; name: string; model: string; kind: string; default: boolean } -export interface KnowledgeJob { - id: string; - status: string; - processed: number; - created_items: number; - duplicates: number; - skipped: number; - model_calls: number; - input_chars: number; - output_chars: number; - error: string; - created_at: string; - total_messages: null | number; - progress_percent: null | number; - completion_reason: string; - pending_items: number; - options: { engine: string; max_model_calls: number; model_provider_id?: string; model_name?: string; model_timeout_seconds?: number; model_format_fallbacks?: number; model_last_warning?: string; max_messages: number; source_name?: string; incremental?: boolean }; -} - -export interface KnowledgePreview { - available_messages: number; - eligible_text_messages: number; - conversations: number; - total_messages: number; - limited: boolean; - cutoff_at: string; - source_name: string; -} - export interface KnowledgeOverview { counts: Record; settings: null | { enabled: boolean; updated_at: string }; @@ -81,8 +55,6 @@ function path(resource: string, accountId: number, extra: Record requestClient.get(path('overview', id), { signal }); -export const fetchKnowledgeJobs = (id: number, signal?: AbortSignal) => - requestClient.get<{ items: KnowledgeJob[] }>(path('jobs', id), { signal }); export const fetchKnowledgeItems = (id: number, status: string, query: string, page: number, signal?: AbortSignal) => requestClient.get<{ items: KnowledgeItem[]; total: number }>(path('items', id, { page, query, status }), { signal }); export const fetchKnowledgeItem = (id: number, item: string) => @@ -91,14 +63,8 @@ export const saveKnowledgeItem = (id: number, item: KnowledgeItem) => requestClient.put(path(`items/${item.id}`, id), item); export const knowledgeItemAction = (id: number, item: KnowledgeItem, action: string, confirmed = false, restoreRevision = 0) => requestClient.post(path(`items/${item.id}/${action}`, id), { confirmed, restore_revision: restoreRevision, revision: item.revision }); -export const fetchKnowledgeModels = (id: number) => +export const fetchKnowledgeModels = (id = -1) => requestClient.get<{ items: KnowledgeModel[] }>(path('models', id)); -export const configureKnowledgeJob = (id: number, job: string, body: Record) => - requestClient.put(path(`jobs/${job}/config`, id), body); -export const createKnowledgeJob = (id: number, body: Record) => - requestClient.post(path('jobs', id), body); -export const knowledgeJobAction = (id: number, job: string, action: string) => - requestClient.post(path(`jobs/${job}/${action}`, id)); export const searchKnowledge = (id: number, query: string) => requestClient.post(path('search', id), { query }); export const setKnowledgeEnabled = (id: number, enabled: boolean) => @@ -111,9 +77,117 @@ export const exportKnowledge = (id: number) => export const setKnowledgeWatch = (id: number, sourceId: string, enabled: boolean) => requestClient.put(path(`watch/${sourceId}`, id), { enabled }); -export const previewKnowledgeJob = (id: number, body: Record) => - requestClient.post(path('jobs/preview', id), body, { timeout: 60000 }); +// ── 整理批次:一次整理全部(或选中的)账号的全部聊天 ───────────────────────── +/** 整理方式与模型参数。max_model_calls = 0 表示不限。 */ +export interface KnowledgeWashOptions { + engine: 'model' | 'rules'; + max_model_calls: number; + /** 一次请求合并几段问答(1 = 一段一请求) */ + model_batch: number; + model_concurrency: number; + model_filter: boolean; + model_provider_id: string; + model_timeout_seconds: number; +} + +export interface KnowledgeRunAccount { + account_id: number; + completion_reason: string; + created_items: number; + duplicates: number; + error: string; + job_id: string; + label: string; + model_calls: number; + model_filtered: number; + pending_items: number; + processed: number; + progress_percent: null | number; + skipped: number; + status: string; + tenant_id: string; + total_messages: null | number; + updated_items: number; +} + +export interface KnowledgeRun { + accounts: KnowledgeRunAccount[]; + created_at: string; + created_items: number; + duplicates: number; + errors: string[]; + id: string; + input_chars: number; + label: string; + /** 上一版按单个账号、单个来源建的任务 */ + legacy: boolean; + model_calls: number; + model_filtered: number; + options: Partial & { date_from?: string; date_to?: string; model_name?: string }; + output_chars: number; + pending_items: number; + processed: number; + progress_percent: null | number; + skip_reasons: Record; + skipped: number; + status: string; + total_messages: null | number; + updated_at: string; + updated_items: number; +} + +export interface KnowledgeAutoSummary { + created_24h: number; + failed_24h: number; + jobs_24h: number; + waiting_conversations: number; + watching_sources: number; +} + +export interface KnowledgeRunPreview { + accounts: { account_id: number; conversations: number; label: string; messages: number; tenant_id: string; text_messages: number }[]; + empty_accounts: number; + estimated_model_calls: number; + scope_label: string; + totals: { conversations: number; messages: number; text_messages: number }; +} + +export interface KnowledgeConfig { + default_engine: 'model' | 'rules'; + default_model_provider_id: string; + incremental_engine: 'model' | 'rules'; + model_batch: number; + model_concurrency: number; + model_filter: boolean; +} + +export interface KnowledgeModelTest { + draft?: { answer: string; category: string; conditions: string; kind: string; question: string; title: string }; + error?: string; + latency_ms: number; + model_name: string; + ok: boolean; + sample?: { answer: string; question: string }; +} + +export interface KnowledgeRunScope { account_ids: number[]; date_from: string; date_to: string } + +export const previewKnowledgeRun = (scope: KnowledgeRunScope) => + requestClient.post('/knowledge/runs/preview', scope, { timeout: 120000 }); +export const createKnowledgeRun = (body: KnowledgeRunScope & KnowledgeWashOptions & { auto_watch: boolean; staff_confirmed: boolean }) => + requestClient.post('/knowledge/runs', body, { timeout: 120000 }); +export const fetchKnowledgeRuns = (signal?: AbortSignal) => + requestClient.get<{ auto: KnowledgeAutoSummary; items: KnowledgeRun[] }>('/knowledge/runs', { signal }); +export const knowledgeRunAction = (runId: string, action: 'cancel' | 'pause' | 'resume') => + requestClient.post(`/knowledge/runs/${encodeURIComponent(runId)}/${action}`); +export const configureKnowledgeRun = (runId: string, body: KnowledgeWashOptions) => + requestClient.put(`/knowledge/runs/${encodeURIComponent(runId)}/config`, body); +export const fetchKnowledgeConfig = () => requestClient.get('/knowledge/config'); +export const saveKnowledgeConfig = (body: KnowledgeConfig) => requestClient.put('/knowledge/config', body); +export const testKnowledgeModel = (modelProviderId: string, timeout = 90) => + requestClient.post('/knowledge/models/test', + { model_provider_id: modelProviderId, model_timeout_seconds: timeout }, { timeout: 200000 }); export interface KnowledgeReviewJob { id: string; account_id: number; scope_label: string; status: string; total: number; processed: number; diff --git a/admin-web/apps/web-antd/src/api/request.ts b/admin-web/apps/web-antd/src/api/request.ts index 97ac9293b..245ad9de4 100644 --- a/admin-web/apps/web-antd/src/api/request.ts +++ b/admin-web/apps/web-antd/src/api/request.ts @@ -15,6 +15,7 @@ import { useAccessStore } from '@vben/stores'; import { message } from 'ant-design-vue'; +import { t } from '#/locales/t'; import { useAuthStore } from '#/store'; import { refreshTokenApi } from './core'; @@ -71,7 +72,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { */ async function doRefreshToken(): Promise { await refreshTokenApi(); - throw new Error('后端未启用令牌续期'); + throw new Error(t('后端未启用令牌续期')); } function formatToken(token: null | string) { diff --git a/admin-web/apps/web-antd/src/bootstrap.ts b/admin-web/apps/web-antd/src/bootstrap.ts index ec7211254..811e1a260 100644 --- a/admin-web/apps/web-antd/src/bootstrap.ts +++ b/admin-web/apps/web-antd/src/bootstrap.ts @@ -1,15 +1,17 @@ -import { createApp, watchEffect } from 'vue'; +import { createApp, watch, watchEffect } from 'vue'; import { registerAccessDirective } from '@vben/access'; import { registerLoadingDirective } from '@vben/common-ui/es/loading'; -import { preferences } from '@vben/preferences'; +import { preferences, updatePreferences } from '@vben/preferences'; import { initStores } from '@vben/stores'; import '@vben/styles'; import '@vben/styles/antd'; +import './styles/layout-fixes.css'; import { useTitle } from '@vueuse/core'; import { $t, setupI18n } from '#/locales'; +import { t } from '#/locales/t'; import { initComponentAdapter } from './adapter/component'; import { initSetupVbenForm } from './adapter/form'; @@ -60,6 +62,22 @@ async function bootstrap(namespace: string) { const { MotionPlugin } = await import('@vben/plugins/motion'); app.use(MotionPlugin); + // 顶栏 Logo、登录页、浏览器标题里的产品名和页脚公司名都直接读偏好设置,框架不翻译它们: + // 语言一切换就把这两个值换成对应语言的写法(.env 里的 VITE_APP_TITLE 是中文原文)。 + const appTitle = import.meta.env.VITE_APP_TITLE; + const companyName = '真羊'; // i18n-ignore 中文原文,英文见 locales/en/menu.ts + watch( + () => preferences.app.locale, + () => { + const name = t(appTitle); + const company = t(companyName); + if (preferences.app.name !== name || preferences.copyright.companyName !== company) { + updatePreferences({ app: { name }, copyright: { companyName: company } }); + } + }, + { immediate: true }, + ); + // 动态更新标题 watchEffect(() => { if (preferences.app.dynamicTitle) { diff --git a/admin-web/apps/web-antd/src/components/archive-account-selector.vue b/admin-web/apps/web-antd/src/components/archive-account-selector.vue index f81cd24c2..e9f3d56ee 100644 --- a/admin-web/apps/web-antd/src/components/archive-account-selector.vue +++ b/admin-web/apps/web-antd/src/components/archive-account-selector.vue @@ -6,6 +6,7 @@ import { onMounted, ref } from 'vue'; import { Select } from 'ant-design-vue'; import { ALL_ACCOUNTS, fetchDesktopAccounts } from '#/api/console'; +import { t } from '#/locales/t'; const props = defineProps<{ modelValue: number; disabled?: boolean }>(); const emit = defineEmits<{ @@ -62,10 +63,10 @@ onMounted(async () => { class="min-w-[240px]" @change="(value) => choose(Number(value))" > - 全部账号 - 旧版未归属数据 + {{ t('全部账号') }} + {{ t('旧版未归属数据') }} - {{ item.nickname || item.zyt_sn }}({{ item.zyt_sn }}) + {{ t('{name}({code})', { code: item.zyt_sn, name: item.nickname || item.zyt_sn }) }} diff --git a/admin-web/apps/web-antd/src/layouts/basic.vue b/admin-web/apps/web-antd/src/layouts/basic.vue index e85dd1cfc..fcfa227c1 100644 --- a/admin-web/apps/web-antd/src/layouts/basic.vue +++ b/admin-web/apps/web-antd/src/layouts/basic.vue @@ -1,89 +1,26 @@ diff --git a/admin-web/apps/web-antd/src/views/_core/authentication/login.vue b/admin-web/apps/web-antd/src/views/_core/authentication/login.vue index 6c13e9ea0..0a4f406b8 100644 --- a/admin-web/apps/web-antd/src/views/_core/authentication/login.vue +++ b/admin-web/apps/web-antd/src/views/_core/authentication/login.vue @@ -10,6 +10,7 @@ import { Button } from 'ant-design-vue'; import { getGroupConfigApi } from '#/api'; import { validateGroupConfig } from '#/utils/group-sso'; +import { t } from '#/locales/t'; import { useAuthStore } from '#/store'; defineOptions({ name: 'Login' }); @@ -30,17 +31,17 @@ onMounted(async () => { const formSchema = computed((): VbenFormSchema[] => [ { component: 'VbenInput', - componentProps: { placeholder: '用户名' }, + componentProps: { placeholder: t('用户名') }, fieldName: 'username', - label: '用户名', - rules: z.string().min(1, { message: '请输入用户名' }), + label: t('用户名'), + rules: z.string().min(1, { message: t('请输入用户名') }), }, { component: 'VbenInputPassword', - componentProps: { placeholder: '密码' }, + componentProps: { placeholder: t('密码') }, fieldName: 'password', - label: '密码', - rules: z.string().min(1, { message: '请输入密码' }), + label: t('密码'), + rules: z.string().min(1, { message: t('请输入密码') }), }, ]); @@ -54,13 +55,13 @@ const formSchema = computed((): VbenFormSchema[] => [ :show-qrcode-login="false" :show-register="false" :show-third-party-login="false" - sub-title="请使用管理员分配的账号登录" - title="真羊 AI 客服 · 管理后台" + :sub-title="t('请使用管理员分配的账号登录')" + :title="t('真羊 AI 客服 · 管理后台')" @submit="authStore.authLogin" > diff --git a/admin-web/apps/web-antd/src/views/_core/profile/base-setting.vue b/admin-web/apps/web-antd/src/views/_core/profile/base-setting.vue index aa8a4c260..6408c1fc2 100644 --- a/admin-web/apps/web-antd/src/views/_core/profile/base-setting.vue +++ b/admin-web/apps/web-antd/src/views/_core/profile/base-setting.vue @@ -8,20 +8,22 @@ import { computed, onMounted, ref } from 'vue'; import { ProfileBaseSetting } from '@vben/common-ui'; import { getUserInfoApi } from '#/api'; +import { t } from '#/locales/t'; const profileBaseSettingRef = ref(); -const MOCK_ROLES_OPTIONS: BasicOption[] = [ +// 选项文案要随语言切换:写成函数,在 computed 里调用 +const mockRolesOptions = (): BasicOption[] => [ { - label: '管理员', + label: t('管理员'), value: 'super', }, { - label: '用户', + label: t('用户'), value: 'user', }, { - label: '测试', + label: t('测试'), value: 'test', }, ]; @@ -31,26 +33,26 @@ const formSchema = computed((): VbenFormSchema[] => { { fieldName: 'realName', component: 'Input', - label: '姓名', + label: t('姓名'), }, { fieldName: 'username', component: 'Input', - label: '用户名', + label: t('用户名'), }, { fieldName: 'roles', component: 'Select', componentProps: { mode: 'tags', - options: MOCK_ROLES_OPTIONS, + options: mockRolesOptions(), }, - label: '角色', + label: t('角色'), }, { fieldName: 'introduction', component: 'Textarea', - label: '个人简介', + label: t('个人简介'), }, ]; }); diff --git a/admin-web/apps/web-antd/src/views/_core/profile/index.vue b/admin-web/apps/web-antd/src/views/_core/profile/index.vue index 8740894e3..ac618bd3d 100644 --- a/admin-web/apps/web-antd/src/views/_core/profile/index.vue +++ b/admin-web/apps/web-antd/src/views/_core/profile/index.vue @@ -1,9 +1,11 @@