chore: docs 结构整改 + compose 双目录对齐(合并重建提交)

本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
This commit is contained in:
Simon
2026-08-07 22:31:32 +08:00
parent 5a77a89ab1
commit facc04aa65
573 changed files with 129347 additions and 909 deletions
@@ -0,0 +1,59 @@
classDiagram
class Agent {
+str id
+str user_id
+str name
+str status
+str role
+list skill_tags
+int current_load
+int max_load
+datetime created_at
+datetime updated_at
}
class SystemConfig {
+str id
+str config_key
+str config_value
+str description
+datetime updated_at
}
class ConfigChangeLog {
+str id
+str config_key
+str old_value
+str new_value
+str changed_by
+datetime changed_at
}
class QuickReplyTemplate {
+str id
+str category
+str title
+str content
+list variables
+int sort_order
+str status
+int version
+str submitted_by
+datetime created_at
+datetime updated_at
}
class Conversation {
+str id
+str employee_id
+str employee_name
+str status
+int urgency_score
+str assigned_agent_id
+datetime created_at
}
ConfigChangeLog --> SystemConfig : tracks changes to
ConfigChangeLog --> Agent : changed_by
QuickReplyTemplate --> Agent : submitted_by
Conversation --> Agent : assigned_agent_id
@@ -0,0 +1,19 @@
sequenceDiagram
participant U as 管理员
participant FE as frontend-admin
participant API as /api/admin/configs/{key}
participant SVC as admin_service
participant DB as PostgreSQL
U->>FE: 切换应急模式开关
FE->>API: PUT /api/admin/configs/emergency_mode
API->>API: require_admin 校验权限
API->>SVC: update_config(key, value, agent_id)
SVC->>DB: SELECT SystemConfig WHERE key=emergency_mode
DB-->>SVC: 当前值 "false"
SVC->>DB: INSERT ConfigChangeLog(old="false", new="true", by=agent_id)
SVC->>DB: UPDATE SystemConfig SET value="true"
DB-->>SVC: 更新成功
SVC-->>API: {key, old_value, new_value, changed_at}
API-->>FE: 返回变更结果
FE->>FE: 显示变更成功提示
@@ -0,0 +1,16 @@
sequenceDiagram
participant U as 管理员(组长)
participant FE as frontend-admin
participant API as /api/agents/login
participant Redis as Redis
participant DB as PostgreSQL
U->>FE: 输入 user_id + name 登录
FE->>API: POST /api/agents/login
API->>DB: 查询 Agent (user_id)
DB-->>API: Agent 记录(含 role 字段)
API->>Redis: 存储 token → user_id 映射
API-->>FE: {agent_info, token, role: "admin"}
FE->>FE: 检查 role === "admin"
FE->>FE: 存储 admin_token 到 localStorage
FE->>FE: 跳转到 /admin/dashboard
@@ -0,0 +1,23 @@
sequenceDiagram
participant A as 坐席(王丽)
participant AG as /api/quick-replies
participant U as 管理员(宋献)
participant ADM as /api/admin/quick-replies
participant DB as PostgreSQL
A->>AG: POST /api/quick-replies (创建模板, status=draft)
A->>AG: PUT /api/quick-replies/{id} (提交审核, status→pending_review)
AG->>DB: UPDATE QuickReplyTemplate SET status='pending_review', submitted_by='wang_li'
U->>ADM: GET /api/admin/quick-replies/pending
ADM->>DB: SELECT WHERE status='pending_review'
DB-->>ADM: 待审核列表
ADM-->>U: 显示待审核模板
U->>ADM: PUT /api/admin/quick-replies/{id}/review (action=approve)
ADM->>DB: UPDATE SET status='approved', version=version+1
DB-->>ADM: 更新成功
A->>AG: GET /api/quick-replies (获取可见模板)
AG->>DB: SELECT WHERE status='approved' OR (status='pending_review' AND submitted_by=自己)
DB-->>AG: 全员可见(approved) + 仅自己(pending_review)
@@ -0,0 +1,137 @@
%% 坐席端 AI 辅助消息框 — 类图
%% 文档版本: v1.0
%% 创建日期: 2026-07-11
classDiagram
direction TB
class WingmanService {
-str api_url
-str api_key
-float timeout
-httpx.AsyncClient _client
-aioredis.Redis _redis
-str _COMPLETION_SYSTEM_PROMPT
-str _TONE_ADJUST_SYSTEM_PROMPT
-str _POLISH_SYSTEM_PROMPT
-str _REWRITE_SYSTEM_PROMPT
+__init__(redis_client: Optional[Redis])
+async generate_completion(conversation_id: str, current_text: str, messages: List[Dict], max_length: int) Dict
+async adjust_tone(conversation_id: str, selected_text: str, full_text: str, tone: str, messages: List[Dict]) Dict
+async polish_text(conversation_id: str, text: str, action: str, messages: List[Dict]) Dict
+async rewrite_versions(conversation_id: str, current_text: str, messages: List[Dict], generate_count: int, include_knowledge: bool) Dict
-async _call_wingman_api(context_messages: List[Dict], temperature: float) Optional[str]
-_build_context_messages(messages: List[Dict], system_prompt: str) List[Dict]
-async _get_cache(key: str) Optional[Dict]
-async _set_cache(key: str, value: Dict, ttl: int) void
-_make_cache_key(text: str, conversation_id: str) str
-async _search_knowledge(query: str) Optional[str]
-_parse_json_response(content: str, default: Dict) Dict
}
class WingmanAPIRouter {
+POST autocomplete(conversation_id, request: AutocompleteRequest, agent, db, wingman_service)
+POST tone_adjust(conversation_id, request: ToneAdjustRequest, agent, db, wingman_service)
+POST polish(conversation_id, request: PolishRequest, agent, db, wingman_service)
+POST rewrite(conversation_id, request: RewriteRequest, agent, db, wingman_service)
-async _validate_conversation(conversation_id, agent, db) Conversation
-async _get_recent_messages(conversation_id, db, limit) List[Dict]
}
class AutocompleteRequest {
+str current_text
+int cursor_position
+int max_length
}
class ToneAdjustRequest {
+str selected_text
+str full_text
+str tone
}
class PolishRequest {
+str text
+str action
+bool conversation_context
}
class RewriteRequest {
+str current_text
+int generate_count
+bool include_knowledge
}
class WingmanTS {
+autocomplete(convId: str, text: str, cursorPos: int, signal: AbortSignal) Promise~AutocompleteResult~
+adjustTone(convId: str, selectedText: str, fullText: str, tone: ToneType, signal: AbortSignal) Promise~ToneAdjustResult~
+polishText(convId: str, text: str, action: PolishAction, signal: AbortSignal) Promise~PolishResult~
+rewriteVersions(convId: str, currentText: str, signal: AbortSignal) Promise~RewriteResult~
}
class UseAiAssist {
+Ref~string~ ghostText
+Ref~boolean~ isCompletLoading
+Ref~boolean~ autocompleteEnabled
+Ref~boolean~ tonePopoverVisible
+Ref~boolean~ polishPanelVisible
+Ref~boolean~ rewritePanelVisible
+triggerAutocomplete() void
+acceptGhostText() void
+clearGhostText() void
+adjustTone(selectedText: str, fullText: str, tone: ToneType) Promise~void~
+polishText(action: PolishAction) Promise~void~
+rewriteVersions() Promise~void~
+cleanup() void
}
class GhostText {
+Props: ghostText: string, textareaRef: HTMLTextAreaElement
+Emits: accept, dismiss
-calculateCursorPos(textarea: HTMLTextAreaElement) {x: number, y: number}
}
class AiAssistToolbar {
+Props: autocompleteEnabled: boolean, hasSelection: boolean, hasText: boolean
+Emits: toggleAutocomplete, toneAdjust, polish, rewrite
}
class ToneAdjustPopover {
+Props: visible: boolean, loading: boolean, result: ToneAdjustResult|null, originalText: string
+Emits: select(tone: ToneType), replace, cancel
}
class PolishPanel {
+Props: visible: boolean, loading: boolean, result: PolishResult|null, originalText: string
+Emits: action(action: PolishAction), replace(text: string), cancel
}
class RewritePanel {
+Props: visible: boolean, loading: boolean, result: RewriteResult|null
+Emits: replace(text: string), append(text: string), cancel
}
class ReplyBox {
-UseAiAssist aiAssist
-Ref~string~ inputText
-HTMLTextAreaElement inputRef
+handleKeydown(event: KeyboardEvent) void
+handleInput() void
}
%% 后端关系
WingmanAPIRouter --> WingmanService : Depends (DI)
WingmanAPIRouter --> AutocompleteRequest : 验证请求体
WingmanAPIRouter --> ToneAdjustRequest : 验证请求体
WingmanAPIRouter --> PolishRequest : 验证请求体
WingmanAPIRouter --> RewriteRequest : 验证请求体
%% 前端关系
WingmanTS --> AutocompleteRequest : HTTP 请求
UseAiAssist --> WingmanTS : 调用 API
ReplyBox --> UseAiAssist : composable
ReplyBox --> GhostText : 渲染幽灵文字
ReplyBox --> AiAssistToolbar : 渲染工具栏
ReplyBox --> ToneAdjustPopover : 弹出浮层
ReplyBox --> PolishPanel : 弹出面板
ReplyBox --> RewritePanel : 弹出面板
@@ -0,0 +1,261 @@
%% 坐席端 AI 辅助消息框 — 时序图
%% 文档版本: v1.0
%% 创建日期: 2026-07-11
%% 包含 4 个功能的完整调用流程
%% ==========================================================================
%% 4.1 实时自动补齐
%% ==========================================================================
sequenceDiagram
participant User as 坐席
participant RB as ReplyBox.vue
participant GT as GhostText.vue
participant Cmp as useAiAssist.ts
participant API as wingman.ts
participant BE as wingman.py<br/>(autocomplete)
participant Svc as WingmanService<br/>.generate_completion()
participant Redis as Redis
participant Dify as Dify AI
User->>RB: 输入文字
RB->>Cmp: handleInput() → triggerAutocomplete()
Note over Cmp: debounce 800ms 等待
Note over Cmp: 输入停顿 > 800ms 触发
Cmp->>Cmp: abort 上一个 AbortController
Cmp->>Cmp: 创建新 AbortController
Note over Cmp: 检查:输入 > 5 字符 && autocompleteEnabled
Cmp->>API: autocomplete(convId, text, cursorPos, signal)
API->>BE: POST /conversations/{id}/wingman/autocomplete
BE->>BE: _validate_conversation()
BE->>BE: _get_recent_messages(limit=5)
BE->>Svc: generate_completion(conv_id, text, messages)
Svc->>Svc: _make_cache_key(text, conv_id)
Svc->>Redis: GET wingman:autocomplete:{hash}
alt Redis 命中缓存
Redis-->>Svc: {completion, confidence}
Svc-->>BE: 缓存结果
else Redis 未命中
Redis-->>Svc: nil
Svc->>Svc: _build_context_messages(messages, _COMPLETION_SYSTEM_PROMPT)
Svc->>Svc: 追加 user 消息: "坐席正在输入:{text}\n请补齐"
Svc->>Dify: POST /chat/completions<br/>{temperature: 0.2}
Dify-->>Svc: "正在查看相关工单记录..."
Svc->>Redis: SETEX wingman:autocomplete:{hash} 30s
Svc-->>BE: {completion, confidence}
end
BE-->>API: {code: 0, data: {completion, confidence}}
API-->>Cmp: AutocompleteResult
Note over Cmp: 检查:未被 abort && completion 非空
Cmp->>Cmp: ghostText.value = completion
Cmp->>GT: 渲染幽灵文字(灰色斜体)
GT-->>User: 光标位置显示灰色补齐文字
alt Tab 键接受
User->>RB: 按 Tab
RB->>Cmp: acceptGhostText()
Cmp->>RB: inputText += ghostText
Cmp->>Cmp: ghostText = ''
GT-->>User: 幽灵文字消失,文字变为正常颜色
else 继续输入
User->>RB: 继续输入
RB->>Cmp: clearGhostText()
Cmp->>Cmp: ghostText = ''
Note over Cmp: 重新触发 debounce
else Esc 键
User->>RB: 按 Esc
RB->>Cmp: clearGhostText()
Cmp->>Cmp: ghostText = ''
end
%% ==========================================================================
%% 4.2 语气调整
%% ==========================================================================
sequenceDiagram
participant User as 坐席
participant RB as ReplyBox.vue
participant TAP as ToneAdjustPopover.vue
participant Cmp as useAiAssist.ts
participant API as wingman.ts
participant BE as wingman.py<br/>(tone-adjust)
participant Svc as WingmanService<br/>.adjust_tone()
participant Dify as Dify AI
User->>RB: 选中输入框文字(≥ 5 字符)
User->>RB: 点击工具栏"语气"按钮
RB->>TAP: visible = true(弹出浮层)
TAP-->>User: 显示 3 种语气选项
User->>TAP: 点击"专业"
TAP->>Cmp: adjustTone(selectedText, fullText, 'professional')
Cmp->>Cmp: toneLoading = true
Cmp->>API: adjustTone(convId, selectedText, fullText, 'professional', signal)
API->>BE: POST /conversations/{id}/wingman/tone-adjust
BE->>BE: _validate_conversation()
BE->>BE: _get_recent_messages(limit=5)
BE->>Svc: adjust_tone(conv_id, selectedText, fullText, 'professional', messages)
Svc->>Svc: _build_context_messages(messages, _TONE_ADJUST_SYSTEM_PROMPT)
Svc->>Svc: 追加 user 消息: "原文:{selectedText}\n完整内容:{fullText}\n改写为{tone}风格"
Svc->>Dify: POST /chat/completions<br/>{temperature: 0.3}
Dify-->>Svc: "经排查,您的VPN连接异常..."
Svc-->>BE: {rewritten_text, tone, changes_summary}
BE-->>API: {code: 0, data: {...}}
API-->>Cmp: ToneAdjustResult
Cmp->>Cmp: toneResult = result
Cmp->>Cmp: toneLoading = false
Cmp->>TAP: 显示原文/改写文对比
TAP-->>User: 原文 → 改写文 + 变更说明
alt 点击"替换"
User->>TAP: 点击"替换"
TAP->>RB: emit('replace', rewritten_text)
RB->>RB: 用 rewritten_text 替换选中区域
RB->>TAP: visible = false
else 点击"取消"或外部
User->>TAP: 点击取消
TAP->>RB: emit('cancel')
RB->>TAP: visible = false
end
%% ==========================================================================
%% 4.3 文字润色
%% ==========================================================================
sequenceDiagram
participant User as 坐席
participant RB as ReplyBox.vue
participant PP as PolishPanel.vue
participant Cmp as useAiAssist.ts
participant API as wingman.ts
participant BE as wingman.py<br/>(polish)
participant Svc as WingmanService<br/>.polish_text()
participant Dify as Dify AI
User->>RB: 点击工具栏"润色"按钮
RB->>PP: visible = true(弹出精修面板)
PP-->>User: 显示原文 + 3 个操作按钮
User->>PP: 点击"扩写"
PP->>Cmp: polishText('expand')
Cmp->>Cmp: polishLoading = true
Cmp->>API: polishText(convId, inputText, 'expand', signal)
API->>BE: POST /conversations/{id}/wingman/polish
BE->>BE: _validate_conversation()
BE->>BE: _get_recent_messages(limit=5)
BE->>Svc: polish_text(conv_id, text, 'expand', messages)
Svc->>Svc: _build_context_messages(messages, _POLISH_SYSTEM_PROMPT)
Svc->>Svc: 追加 user 消息: "对以下文字进行扩写:{text}"
Svc->>Dify: POST /chat/completions<br/>{temperature: 0.3}
Dify-->>Svc: "建议您按以下步骤操作:\n1. 退出VPN..."
Svc-->>BE: {polished_text, action, changes_summary}
BE-->>API: {code: 0, data: {...}}
API-->>Cmp: PolishResult
Cmp->>Cmp: polishResult = result
Cmp->>Cmp: polishLoading = false
Cmp->>PP: 显示左右对比(原文 | 结果)
PP-->>User: 左侧原文 | 右侧结果(可编辑)+ 变更说明
Note over User,PP: 坐席可在结果区域手动编辑
alt 点击"替换全部"
User->>PP: 编辑后点击"替换全部"
PP->>RB: emit('replace', editedText)
RB->>RB: inputText = editedText
RB->>PP: visible = false
else 切换操作
User->>PP: 点击"压缩"或"纠错"
PP->>Cmp: polishText('compress' | 'correct')
Note over Cmp,Dify: 重新调用流程
else 点击"取消"
User->>PP: 点击取消
PP->>RB: emit('cancel')
RB->>PP: visible = false
end
%% ==========================================================================
%% 4.4 智能改写
%% ==========================================================================
sequenceDiagram
participant User as 坐席
participant RB as ReplyBox.vue
participant RP as RewritePanel.vue
participant Cmp as useAiAssist.ts
participant API as wingman.ts
participant BE as wingman.py<br/>(rewrite)
participant Svc as WingmanService<br/>.rewrite_versions()
participant RAG as RAGFlow
participant Dify as Dify AI
User->>RB: 点击工具栏"改写"按钮
RB->>RP: visible = true(弹出选择面板)
RP->>Cmp: rewriteVersions()
Cmp->>Cmp: rewriteLoading = true
Cmp->>API: rewriteVersions(convId, inputText, signal)
API->>BE: POST /conversations/{id}/wingman/rewrite
BE->>BE: _validate_conversation()
BE->>BE: _get_recent_messages(limit=10)
BE->>Svc: rewrite_versions(conv_id, text, messages, generate_count=3, include_knowledge=true)
Note over Svc: 构建上下文
Svc->>Svc: _build_context_messages(messages, _REWRITE_SYSTEM_PROMPT)
par 知识库检索(并行)
Svc->>RAG: retrieval(question=当前问题, dataset_ids=默认知识库)
RAG-->>Svc: 检索到 3 个相关文档片段
Svc->>Svc: 将知识片段拼入 system prompt
and 准备 Dify 调用
Note over Svc: 等待知识检索完成
end
Svc->>Dify: POST /chat/completions<br/>{temperature: 0.6}
Dify-->>Svc: "版本1\n---\n版本2\n---\n版本3"
Svc->>Svc: 按 "---" 分割为 3 个版本
Svc->>Svc: 标注 style 和 source
Svc-->>BE: {versions: [{text, style, source}, ...]}
BE-->>API: {code: 0, data: {...}}
API-->>Cmp: RewriteResult
Cmp->>Cmp: rewriteResult = result
Cmp->>Cmp: rewriteLoading = false
Cmp->>RP: 显示 3 个版本卡片
RP-->>User: 版本1(简洁直接)/ 版本2(详细带步骤)/ 版本3(带知识库引用)
alt 选择版本 → 替换
User->>RP: 点击版本卡片 → "替换"
RP->>RB: emit('replace', versionText)
RB->>RB: inputText = versionText
RB->>RP: visible = false
else 选择版本 → 追加
User->>RP: 点击版本卡片 → "追加"
RP->>RB: emit('append', versionText)
RB->>RB: inputText += '\n' + versionText
RB->>RP: visible = false
else 点击"取消"
User->>RP: 点击取消
RP->>RB: emit('cancel')
RB->>RP: visible = false
end
@@ -0,0 +1,77 @@
classDiagram
direction TB
class Employee {
+str id
+str corp_id
+str employee_id
+str name
+str department
+str position
+str mobile
+str email
+str avatar
+int status
+str it_level ★NEW
+str it_level_source ★NEW
+dict notes ★NEW
+datetime last_login_at
+datetime created_at
+datetime updated_at
}
class Conversation {
+str id
+str corp_id
+str employee_id
+str employee_name
+str department
+str status
+bool is_vip
+bool is_pinned
+bool is_todo
+int urgency_score
+dict tags
+str assigned_agent_id
+list collaborating_agent_ids
+int impact_scope ★NEW
+bool is_blocking ★NEW
+str emotion_state ★NEW
+datetime last_message_at
+str last_message_summary
+datetime created_at
+datetime updated_at
}
class TodoItem {
+str id
+str type
+str title
+str priority
+dict description
+str status
+str assigned_agent_id
+str corp_id
+datetime created_at
+datetime updated_at
}
class TroubleshootingTemplate {
+str id
+str name
+str category
+list path_steps
+dict flowchart
+bool is_active
+datetime created_at
+datetime updated_at
}
Employee "1" --> "*" Conversation : has
Conversation "1" --> "*" TodoItem : may generate
TroubleshootingTemplate "1" --> "0..1" Conversation : applied to
note for Employee "it_level: bronze|silver|gold|platinum|diamond|star|king\nit_level_source: system|manual"
note for Conversation "impact_scope: 受影响人数\nis_blocking: 是否阻断性\nemotion_state: normal|anxious|angry|urgent"
note for TodoItem "type: ticket|approval|device\npriority: urgent|high|normal\nstatus: pending|processing|resolved"
note for TroubleshootingTemplate "category: vpn|email|system|account\npath_steps: [{label, status}]\nflowchart: 递归树结构"
@@ -0,0 +1,22 @@
sequenceDiagram
participant U as 坐席
participant TB as TopBar.vue
participant TS as useThemeStore
participant UT as useTheme.ts
participant DOM as document.documentElement
participant LS as localStorage
U->>TB: 点击 ☀️/🌙 切换开关
TB->>TS: toggleTheme()
TS->>TS: currentTheme = currentTheme === 'light' ? 'dark' : 'light'
TS->>UT: applyTheme(currentTheme)
UT->>DOM: setAttribute('data-theme', theme)
UT->>LS: setItem('theme', theme)
DOM-->>DOM: CSS 变量自动切换(:root / [data-theme="dark"]
DOM-->>U: 300ms 过渡动画,界面变色
Note over U,LS: 页面加载时
U->>UT: 首次进入页面
UT->>LS: getItem('theme')
LS-->>UT: 'dark' | 'light' | null
UT->>DOM: setAttribute('data-theme', theme || 'light')
@@ -0,0 +1,62 @@
sequenceDiagram
participant Browser as 坐席浏览器
participant App as Vue3 App
participant Store as Pinia Store
participant API as Backend API
participant DB as PostgreSQL
Note over Browser,App: 页面加载
Browser->>App: 挂载 Workspace.vue
App->>Store: 初始化 conversationStore
Store->>API: GET /api/conversations
API->>DB: SELECT * FROM conversations WHERE status IN ('queued','serving') ORDER BY ...
DB-->>API: 会话列表
API-->>Store: 会话数据
Store-->>App: 渲染会话列表
loop 每3秒 setInterval
App->>Store: pollConversations()
Store->>API: GET /api/conversations?page=1&page_size=50
API->>DB: SELECT ... (同上)
DB-->>API: 最新会话列表
API-->>Store: 最新数据
alt 数据有变化
Store->>Store: diff 比较,更新变化的会话
Store-->>App: 触发响应式更新
App->>App: 更新列表项标签/排序/未读数
else 数据无变化
Store-->>App: 无需更新
end
end
Note over App: 用户点击某个会话
App->>Store: selectConversation(id)
Store->>API: GET /api/conversations/{id}/messages?limit=50
API->>DB: SELECT * FROM messages WHERE conversation_id=... ORDER BY created_at
DB-->>API: 消息列表
API-->>Store: messages
Store-->>App: 渲染对话区
loop 选中会话的消息轮询
App->>Store: pollMessages(conv_id)
Store->>API: GET /api/conversations/{id}/messages?limit=20&before=latest_id
API->>DB: SELECT ... WHERE created_at > latest
DB-->>API: 新消息
API-->>Store: 新消息列表
alt 有新消息
Store->>Store: 追加消息到列表
Store-->>App: 滚动到底部,显示新消息
end
end
Note over App: 坐席发送回复
App->>Store: sendMessage(conv_id, content)
Store->>API: POST /api/conversations/{id}/messages {content}
API->>DB: INSERT INTO messages ...
API-->>Store: 发送的消息对象
Store->>Store: 追加到消息列表
Store-->>App: 显示在对话区
@@ -0,0 +1,69 @@
sequenceDiagram
participant WX as 企微消息
participant API as FastAPI
participant Router as MessageRouter
participant Score as ScoringService
participant Vip as VipService
participant DB as PostgreSQL
participant Redis as Redis
WX->>API: 员工消息回调
API->>Router: route_message(msg)
rect rgb(255, 240, 240)
Note over Router,Score: Step 1: VIP检测
Router->>Vip: is_vip(employee_id)
Vip->>Redis: GET vip_cache:{employee_id}
alt 缓存命中
Redis-->>Vip: is_vip=True/False
else 缓存未命中
Vip->>Vip: get_user_info(employee_id)
Vip->>Vip: _check_vip_rules(user_info)
Note over Vip: 规则: 总监及以上 或 关键部门
Vip->>Redis: SET vip_cache:{employee_id} EX 3600
end
Vip-->>Router: is_vip=True/False
end
rect rgb(255, 255, 220)
Note over Router,Score: Step 2: 情绪关键词检测
Router->>Score: detect_emotion(msg)
Score->>DB: SELECT FROM system_configs WHERE key LIKE 'emotion_keywords_%'
DB-->>Score: 关键词列表
Score->>Score: 遍历关键词匹配消息内容
Score-->>Router: emotion="urgent"
end
rect rgb(220, 255, 220)
Note over Router,Score: Step 3: 举手检测
Router->>Score: detect_hand_raise(msg)
Score->>DB: SELECT FROM system_configs WHERE key='hand_raise_keywords'
DB-->>Score: ["转人工","人工",...]
Score->>Score: 遍历关键词匹配
Score-->>Router: hand_raise=True
end
rect rgb(220, 220, 255)
Note over Router,Score: Step 4: 需介入检测
Router->>Score: detect_need_intervene(conv)
Score->>DB: SELECT COUNT FROM messages WHERE conversation_id=... AND sender_type='employee'
DB-->>Score: 员工消息数
Score->>DB: SELECT FROM system_configs WHERE key='intervene_round_threshold'
DB-->>Score: 3
Score->>Score: 员工连续追问 > 3轮?
Score-->>Router: need_intervene=True
end
rect rgb(255, 220, 255)
Note over Router,Score: Step 5: 紧急度计算
Router->>Score: calculate_urgency(conv, msg)
Score->>Score: base = keyword_score(1)
Score->>Score: + emotion_bonus(1)
Score->>Score: + vip_bonus(1)
Score->>Score: + repeat_bonus(1)
Score->>Score: total = min(5, base+emotion+vip+repeat)
Score-->>Router: urgency_score=4
end
Router->>DB: UPDATE conversations SET tags=..., urgency_score=4, is_vip=...
Router-->>API: 更新后的会话
@@ -0,0 +1,43 @@
sequenceDiagram
participant Emp as 员工(H5页面)
participant API as FastAPI
participant ConvSvc as ConversationService
participant Score as ScoringService
participant WXSvc as WecomService
participant Agent as 坐席工作台
participant DB as PostgreSQL
Emp->>API: POST /api/h5/conversation/shake
API->>ConvSvc: find_or_create_conversation(employee_id)
ConvSvc->>DB: 查询/创建 conversation
DB-->>ConvSvc: conversation
API->>Score: detect_hand_raise("摇人")
Score-->>API: hand_raise=True
API->>ConvSvc: update_conversation(tags={hand_raise:true})
ConvSvc->>DB: UPDATE conversations SET tags=...
API->>ConvSvc: get_funny_phrase(scene='shake')
ConvSvc->>DB: SELECT FROM funny_phrases WHERE scene='shake'
DB-->>ConvSvc: "大哥,俺这就去摇人,稍等..."
ConvSvc->>ConvSvc: send_message(conv_id, 'system', 趣味话术)
ConvSvc->>WXSvc: send_text_message(employee_id, 趣味话术)
API-->>Emp: {conversation, funny_phrase}
Note over Emp: H5显示摇人动画 + 趣味话术
loop 坐席轮询
Agent->>API: GET /api/conversations
API->>ConvSvc: get_conversations()
ConvSvc->>DB: SELECT ... ORDER BY urgency DESC
DB-->>ConvSvc: 列表(举手会话靠前)
API-->>Agent: 举手标记的会话(黄色标签)
end
Agent->>API: POST /api/conversations/{id}/assign {agent_id}
API->>ConvSvc: update_conversation(status='serving', assigned_agent_id)
ConvSvc->>DB: UPDATE conversations SET status='serving'
ConvSvc->>ConvSvc: send_message(conv_id, 'system', '人摇来了!IT坐席为您服务')
ConvSvc->>WXSvc: send_text_message(employee_id, 接入话术)
ConvSvc-->>API: updated conversation
API-->>Agent: 接单成功
WXSvc-->>Emp: 企微推送"人摇来了!IT坐席为您服务"
@@ -0,0 +1,58 @@
%% 方案A — 坐席端 Web Speech API 实时转写时序图
sequenceDiagram
participant U as 坐席
participant RB as ReplyBox.vue
participant USR as useSpeechRecognition
participant SR as SpeechRecognition
Note over U,SR: 阶段1:初始化
RB->>USR: useSpeechRecognition('zh-CN')
USR->>USR: 检查 window.SpeechRecognition || webkitSpeechRecognition
alt 浏览器不支持
USR-->>RB: state.isSupported = false
RB->>RB: 语音按钮显示为禁用/隐藏
else 浏览器支持
USR->>USR: state.isSupported = true
end
Note over U,SR: 阶段2:开始识别
U->>RB: 点击语音按钮 🎤
RB->>USR: start()
USR->>SR: new SpeechRecognition()
USR->>SR: lang='zh-CN', continuous=true, interimResults=true
USR->>SR: 注册 onresult / onerror / onend 回调
USR->>SR: start()
SR-->>USR: onstart 触发
USR-->>RB: state.isListening = true
RB->>RB: 按钮变红 + textarea 显示光标 + 提示"正在聆听..."
Note over U,SR: 阶段3:实时转写(边说边出字)
U->>SR: 说话:"帮我查一下"
SR-->>USR: onresult(event) — interimResults (isFinal=false)
USR->>USR: state.interimText = "帮我查一下"
USR-->>RB: 实时更新 textarea(灰色临时文字)
RB->>RB: inputText.value = finalText + interimText
U->>SR: 继续说话:"帮我查一下密码"
SR-->>USR: onresult(event) — final result (isFinal=true)
USR->>USR: state.finalText += "帮我查一下密码"
USR-->>RB: 确定文字追加到 textarea
RB->>RB: inputText.value = finalText
Note over U,SR: 阶段4:停止识别
U->>RB: 再次点击语音按钮
RB->>USR: stop()
USR->>SR: stop()
SR-->>USR: onend 触发
USR-->>RB: state.isListening = false
RB->>RB: 按钮恢复 + textarea 保持已识别文字
Note over U,SR: 异常分支
alt 识别出错
SR-->>USR: onerror(event) — error='not-allowed'
USR-->>RB: showToast("麦克风权限被拒绝")
end
alt 用户长时间不说话
SR-->>USR: onend 自动触发(超时停止)
USR-->>RB: state.isListening = false
end
@@ -0,0 +1,82 @@
%% 语音识别转文字 — 类图
classDiagram
class Wx {
<<interface>>
+config(options: WxConfigOptions) void
+ready(callback: Function) void
+error(callback: Function) void
+startRecord() void
+stopRecord(options: object) void
+translateVoice(options: object) void
}
class JsapiConfig {
+corp_id: string
+agent_id: string
+timestamp: number
+nonce_str: string
+signature: string
}
class UseWecomVoiceReturn {
+state: VoiceState
+init() Promise~void~
+startRecording() Promise~void~
+stopAndTranslate() Promise~string~
+isSupported() boolean
}
class VoiceState {
+isReady: boolean
+isRecording: boolean
+isTranslating: boolean
+error: string|null
}
class SpeechRecognition {
<<interface>>
+lang: string
+continuous: boolean
+interimResults: boolean
+start() void
+stop() void
+abort() void
+onresult: Function
+onerror: Function
+onend: Function
}
class UseSpeechRecognitionReturn {
+state: SpeechState
+start() void
+stop() void
+reset() void
}
class SpeechState {
+isListening: boolean
+interimText: string
+finalText: string
+error: string|null
+isSupported: boolean
}
class InputBar {
+inputText: string
+handleVoiceStart() void
+handleVoiceEnd() void
}
class ReplyBox {
+inputText: string
+toggleVoice() void
}
UseWecomVoiceReturn --> VoiceState
UseWecomVoiceReturn ..> Wx : uses
UseWecomVoiceReturn ..> JsapiConfig : fetches
InputBar --> UseWecomVoiceReturn : composes
UseSpeechRecognitionReturn --> SpeechState
UseSpeechRecognitionReturn ..> SpeechRecognition : wraps
ReplyBox --> UseSpeechRecognitionReturn : composes
@@ -0,0 +1,52 @@
%% 方案C — H5端企微 JS-SDK 录音转文字时序图
sequenceDiagram
participant U as 用户
participant IB as InputBar.vue
participant UWV as useWecomVoice
participant API as /api/wecom/jsapi-config
participant WX as 企微服务器
participant WV as 企微WebView
Note over U,WV: 阶段1:初始化(页面加载时,幂等)
IB->>UWV: init()
UWV->>WV: 检查 window.wx 是否存在
alt JS-SDK 未加载
UWV->>WV: 动态加载 jweixin-1.2.0.js
end
UWV->>API: GET /api/wecom/jsapi-config?url=当前页面URL
API->>API: get_jsapi_ticket() + sha1 签名
API-->>UWV: { corp_id, agent_id, timestamp, nonce_str, signature }
UWV->>WV: wx.config({ appId, timestamp, ..., jsApiList: ['startRecord','stopRecord','translateVoice'] })
WV-->>UWV: wx.ready() 回调
UWV-->>IB: state.isReady = true
Note over U,WV: 阶段2:录音
U->>IB: 按下语音按钮 🎤
IB->>UWV: startRecording()
UWV->>WV: wx.startRecord()
UWV-->>IB: state.isRecording = true
IB->>IB: UI 切换为录音状态(按钮变红+提示"松开识别文字"
Note over U,WV: 阶段3:停止录音+转文字
U->>IB: 松开语音按钮
IB->>UWV: stopAndTranslate()
UWV->>WV: wx.stopRecord()
WV-->>UWV: { localId: "wxLocalId_xxx" }
UWV-->>IB: state.isTranslating = true
UWV->>WV: wx.translateVoice({ localId, isShowProgressTips: 1 })
WV->>WX: 上传录音+请求语音识别
WX-->>WV: 返回识别文字
WV-->>UWV: { translateResult: "帮我重置密码" }
UWV-->>IB: 返回识别文字
IB->>IB: inputText.value += translateResult
IB->>IB: state.isTranslating = false, state.isRecording = false
Note over U,WV: 异常分支
alt 录音超过60秒
WV-->>UWV: 自动触发 stopRecord 回调
UWV->>UWV: 自动执行 translateVoice
end
alt 转文字失败
WV-->>UWV: fail 回调 { errMsg }
UWV-->>IB: showToast("语音识别失败")
end