feat(ASA): 实现 Phase 8-12 前端管理页面 [AC-ASA-23~AC-ASA-44]
实现内容: - Phase 8: Prompt 模板管理页面(列表、编辑、版本历史、发布/回滚) - Phase 9: 多知识库管理页面(卡片列表、文档管理) - Phase 10: 意图规则管理页面(动态表单、关键词/正则输入组件) - Phase 11: 话术流程管理页面(步骤拖拽编辑、流程预览) - Phase 12: 输出护栏管理页面(禁词管理、行为规则) 新增文件: - src/types/prompt-template.ts, knowledge-base.ts, intent-rule.ts, script-flow.ts, guardrail.ts - src/api/prompt-template.ts, knowledge-base.ts, intent-rule.ts, script-flow.ts, guardrail.ts - src/views/admin/prompt-template/index.vue, components/TemplateDetail.vue - src/views/admin/knowledge-base/index.vue, components/DocumentList.vue - src/views/admin/intent-rule/index.vue, components/KeywordInput.vue, components/PatternInput.vue - src/views/admin/script-flow/index.vue, components/FlowPreview.vue - src/views/admin/guardrail/index.vue, components/ForbiddenWordsTab.vue, components/BehaviorRulesTab.vue 更新: - src/router/index.ts - 添加 5 个新路由 - package.json - 添加 vuedraggable 依赖 - docs/progress/ai-service-admin-progress.md - 更新进度 - spec/ai-service-admin/tasks.md - 更新任务状态
This commit is contained in:
parent
d4b0bc3101
commit
932d4d15ab
|
|
@ -12,6 +12,7 @@
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"element-plus": "^2.6.1",
|
"element-plus": "^2.6.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
ForbiddenWord,
|
||||||
|
ForbiddenWordCreate,
|
||||||
|
ForbiddenWordUpdate,
|
||||||
|
ForbiddenWordListResponse,
|
||||||
|
BehaviorRule,
|
||||||
|
BehaviorRuleCreate,
|
||||||
|
BehaviorRuleUpdate,
|
||||||
|
BehaviorRuleListResponse
|
||||||
|
} from '@/types/guardrail'
|
||||||
|
|
||||||
|
export function listForbiddenWords(params?: {
|
||||||
|
category?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
}): Promise<ForbiddenWordListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/guardrails/forbidden-words',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getForbiddenWord(wordId: string): Promise<ForbiddenWord> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/guardrails/forbidden-words/${wordId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createForbiddenWord(data: ForbiddenWordCreate): Promise<ForbiddenWord> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/guardrails/forbidden-words',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateForbiddenWord(wordId: string, data: ForbiddenWordUpdate): Promise<ForbiddenWord> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/guardrails/forbidden-words/${wordId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteForbiddenWord(wordId: string): Promise<void> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/guardrails/forbidden-words/${wordId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBehaviorRules(params?: {
|
||||||
|
category?: string
|
||||||
|
}): Promise<BehaviorRuleListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/guardrails/behavior-rules',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBehaviorRule(ruleId: string): Promise<BehaviorRule> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/guardrails/behavior-rules/${ruleId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBehaviorRule(data: BehaviorRuleCreate): Promise<BehaviorRule> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/guardrails/behavior-rules',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBehaviorRule(ruleId: string, data: BehaviorRuleUpdate): Promise<BehaviorRule> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/guardrails/behavior-rules/${ruleId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBehaviorRule(ruleId: string): Promise<void> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/guardrails/behavior-rules/${ruleId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ForbiddenWord,
|
||||||
|
ForbiddenWordCreate,
|
||||||
|
ForbiddenWordUpdate,
|
||||||
|
ForbiddenWordListResponse,
|
||||||
|
BehaviorRule,
|
||||||
|
BehaviorRuleCreate,
|
||||||
|
BehaviorRuleUpdate,
|
||||||
|
BehaviorRuleListResponse
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
IntentRule,
|
||||||
|
IntentRuleCreate,
|
||||||
|
IntentRuleUpdate,
|
||||||
|
IntentRuleListResponse
|
||||||
|
} from '@/types/intent-rule'
|
||||||
|
|
||||||
|
export function listIntentRules(params?: {
|
||||||
|
response_type?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
}): Promise<IntentRuleListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/intent-rules',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntentRule(ruleId: string): Promise<IntentRule> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/intent-rules/${ruleId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIntentRule(data: IntentRuleCreate): Promise<IntentRule> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/intent-rules',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateIntentRule(ruleId: string, data: IntentRuleUpdate): Promise<IntentRule> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/intent-rules/${ruleId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteIntentRule(ruleId: string): Promise<void> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/intent-rules/${ruleId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
IntentRule,
|
||||||
|
IntentRuleCreate,
|
||||||
|
IntentRuleUpdate,
|
||||||
|
IntentRuleListResponse
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
KnowledgeBase,
|
||||||
|
KnowledgeBaseCreate,
|
||||||
|
KnowledgeBaseUpdate,
|
||||||
|
KnowledgeBaseListResponse,
|
||||||
|
Document,
|
||||||
|
DocumentListResponse,
|
||||||
|
IndexJob
|
||||||
|
} from '@/types/knowledge-base'
|
||||||
|
|
||||||
|
export function listKnowledgeBases(params?: {
|
||||||
|
kb_type?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
}): Promise<KnowledgeBaseListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/kb/knowledge-bases',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKnowledgeBase(kbId: string): Promise<KnowledgeBase> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/kb/knowledge-bases/${kbId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKnowledgeBase(data: KnowledgeBaseCreate): Promise<KnowledgeBase> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/kb/knowledge-bases',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateKnowledgeBase(kbId: string, data: KnowledgeBaseUpdate): Promise<KnowledgeBase> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/kb/knowledge-bases/${kbId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteKnowledgeBase(kbId: string): Promise<void> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/kb/knowledge-bases/${kbId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDocuments(params: {
|
||||||
|
kb_id?: string
|
||||||
|
status?: string
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<DocumentListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/kb/documents',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIndexJob(jobId: string): Promise<IndexJob> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/kb/index/jobs/${jobId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDocument(docId: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/kb/documents/${docId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
KnowledgeBase,
|
||||||
|
KnowledgeBaseCreate,
|
||||||
|
KnowledgeBaseUpdate,
|
||||||
|
KnowledgeBaseListResponse,
|
||||||
|
Document,
|
||||||
|
DocumentListResponse,
|
||||||
|
IndexJob
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
PromptTemplate,
|
||||||
|
PromptTemplateDetail,
|
||||||
|
PromptTemplateCreate,
|
||||||
|
PromptTemplateUpdate,
|
||||||
|
PromptTemplateListResponse,
|
||||||
|
PublishRequest,
|
||||||
|
RollbackRequest
|
||||||
|
} from '@/types/prompt-template'
|
||||||
|
|
||||||
|
export function listPromptTemplates(params?: { scene?: string }): Promise<PromptTemplateListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/prompt-templates',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPromptTemplate(tplId: string): Promise<PromptTemplateDetail> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/prompt-templates/${tplId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptTemplate(data: PromptTemplateCreate): Promise<PromptTemplate> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/prompt-templates',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePromptTemplate(tplId: string, data: PromptTemplateUpdate): Promise<PromptTemplate> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/prompt-templates/${tplId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePromptTemplate(tplId: string): Promise<void> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/prompt-templates/${tplId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPromptTemplate(tplId: string, data: PublishRequest): Promise<{ success: boolean; message: string }> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/prompt-templates/${tplId}/publish`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollbackPromptTemplate(tplId: string, data: RollbackRequest): Promise<{ success: boolean; message: string }> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/prompt-templates/${tplId}/rollback`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PromptTemplate,
|
||||||
|
PromptTemplateDetail,
|
||||||
|
PromptTemplateCreate,
|
||||||
|
PromptTemplateUpdate,
|
||||||
|
PromptTemplateListResponse
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
ScriptFlow,
|
||||||
|
ScriptFlowDetail,
|
||||||
|
ScriptFlowCreate,
|
||||||
|
ScriptFlowUpdate,
|
||||||
|
ScriptFlowListResponse
|
||||||
|
} from '@/types/script-flow'
|
||||||
|
|
||||||
|
export function listScriptFlows(params?: {
|
||||||
|
is_enabled?: boolean
|
||||||
|
}): Promise<ScriptFlowListResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/script-flows',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScriptFlow(flowId: string): Promise<ScriptFlowDetail> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/script-flows/${flowId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScriptFlow(data: ScriptFlowCreate): Promise<ScriptFlow> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/script-flows',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateScriptFlow(flowId: string, data: ScriptFlowUpdate): Promise<ScriptFlow> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/script-flows/${flowId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteScriptFlow(flowId: string): Promise<void> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/script-flows/${flowId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ScriptFlow,
|
||||||
|
ScriptFlowDetail,
|
||||||
|
ScriptFlowCreate,
|
||||||
|
ScriptFlowUpdate,
|
||||||
|
ScriptFlowListResponse
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,36 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
name: 'LLMConfig',
|
name: 'LLMConfig',
|
||||||
component: () => import('@/views/admin/llm/index.vue'),
|
component: () => import('@/views/admin/llm/index.vue'),
|
||||||
meta: { title: 'LLM 模型配置' }
|
meta: { title: 'LLM 模型配置' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/prompt-templates',
|
||||||
|
name: 'PromptTemplate',
|
||||||
|
component: () => import('@/views/admin/prompt-template/index.vue'),
|
||||||
|
meta: { title: 'Prompt 模板管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/knowledge-bases',
|
||||||
|
name: 'KnowledgeBase',
|
||||||
|
component: () => import('@/views/admin/knowledge-base/index.vue'),
|
||||||
|
meta: { title: '多知识库管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/intent-rules',
|
||||||
|
name: 'IntentRule',
|
||||||
|
component: () => import('@/views/admin/intent-rule/index.vue'),
|
||||||
|
meta: { title: '意图规则管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/script-flows',
|
||||||
|
name: 'ScriptFlow',
|
||||||
|
component: () => import('@/views/admin/script-flow/index.vue'),
|
||||||
|
meta: { title: '话术流程管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/guardrails',
|
||||||
|
name: 'Guardrail',
|
||||||
|
component: () => import('@/views/admin/guardrail/index.vue'),
|
||||||
|
meta: { title: '输出护栏管理' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
export interface ForbiddenWord {
|
||||||
|
id: string
|
||||||
|
word: string
|
||||||
|
category: 'competitor' | 'sensitive' | 'political' | 'custom'
|
||||||
|
strategy: 'mask' | 'replace' | 'block'
|
||||||
|
replacement?: string
|
||||||
|
fallback_message?: string
|
||||||
|
hit_count: number
|
||||||
|
is_enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForbiddenWordCreate {
|
||||||
|
word: string
|
||||||
|
category: 'competitor' | 'sensitive' | 'political' | 'custom'
|
||||||
|
strategy: 'mask' | 'replace' | 'block'
|
||||||
|
replacement?: string
|
||||||
|
fallback_message?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForbiddenWordUpdate {
|
||||||
|
word?: string
|
||||||
|
category?: 'competitor' | 'sensitive' | 'political' | 'custom'
|
||||||
|
strategy?: 'mask' | 'replace' | 'block'
|
||||||
|
replacement?: string
|
||||||
|
fallback_message?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForbiddenWordListResponse {
|
||||||
|
data: ForbiddenWord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorRule {
|
||||||
|
id: string
|
||||||
|
description: string
|
||||||
|
category: 'compliance' | 'tone' | 'boundary' | 'custom'
|
||||||
|
is_enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorRuleCreate {
|
||||||
|
description: string
|
||||||
|
category: 'compliance' | 'tone' | 'boundary' | 'custom'
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorRuleUpdate {
|
||||||
|
description?: string
|
||||||
|
category?: 'compliance' | 'tone' | 'boundary' | 'custom'
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorRuleListResponse {
|
||||||
|
data: BehaviorRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORD_CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'competitor', label: '竞品名称', color: 'danger' },
|
||||||
|
{ value: 'sensitive', label: '敏感词', color: 'warning' },
|
||||||
|
{ value: 'political', label: '政治敏感', color: 'danger' },
|
||||||
|
{ value: 'custom', label: '自定义', color: 'info' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const WORD_STRATEGY_OPTIONS = [
|
||||||
|
{ value: 'mask', label: '脱敏处理', description: '将敏感词替换为 ***' },
|
||||||
|
{ value: 'replace', label: '替换文本', description: '替换为指定文本' },
|
||||||
|
{ value: 'block', label: '拦截输出', description: '阻止输出并返回兜底话术' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BEHAVIOR_CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'compliance', label: '合规要求', color: 'danger' },
|
||||||
|
{ value: 'tone', label: '语气风格', color: 'warning' },
|
||||||
|
{ value: 'boundary', label: '边界限制', color: 'primary' },
|
||||||
|
{ value: 'custom', label: '自定义', color: 'info' }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
export interface IntentRule {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
keywords: string[]
|
||||||
|
patterns: string[]
|
||||||
|
response_type: 'fixed' | 'rag' | 'flow' | 'transfer'
|
||||||
|
priority: number
|
||||||
|
fixed_reply?: string
|
||||||
|
target_kb_ids?: string[]
|
||||||
|
flow_id?: string
|
||||||
|
transfer_message?: string
|
||||||
|
hit_count: number
|
||||||
|
is_enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentRuleCreate {
|
||||||
|
name: string
|
||||||
|
keywords?: string[]
|
||||||
|
patterns?: string[]
|
||||||
|
response_type: 'fixed' | 'rag' | 'flow' | 'transfer'
|
||||||
|
priority: number
|
||||||
|
fixed_reply?: string
|
||||||
|
target_kb_ids?: string[]
|
||||||
|
flow_id?: string
|
||||||
|
transfer_message?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentRuleUpdate {
|
||||||
|
name?: string
|
||||||
|
keywords?: string[]
|
||||||
|
patterns?: string[]
|
||||||
|
response_type?: 'fixed' | 'rag' | 'flow' | 'transfer'
|
||||||
|
priority?: number
|
||||||
|
fixed_reply?: string
|
||||||
|
target_kb_ids?: string[]
|
||||||
|
flow_id?: string
|
||||||
|
transfer_message?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentRuleListResponse {
|
||||||
|
data: IntentRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESPONSE_TYPE_OPTIONS = [
|
||||||
|
{ value: 'fixed', label: '固定回复', color: 'primary' },
|
||||||
|
{ value: 'rag', label: '知识库检索', color: 'success' },
|
||||||
|
{ value: 'flow', label: '话术流程', color: 'warning' },
|
||||||
|
{ value: 'transfer', label: '转人工', color: 'danger' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const RESPONSE_TYPE_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
fixed: { label: '固定回复', color: 'primary' },
|
||||||
|
rag: { label: '知识库检索', color: 'success' },
|
||||||
|
flow: { label: '话术流程', color: 'warning' },
|
||||||
|
transfer: { label: '转人工', color: 'danger' }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
export interface KnowledgeBase {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kbType: 'product' | 'faq' | 'script' | 'policy' | 'general'
|
||||||
|
description?: string
|
||||||
|
priority: number
|
||||||
|
isEnabled: boolean
|
||||||
|
docCount: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBaseCreate {
|
||||||
|
name: string
|
||||||
|
kb_type: 'product' | 'faq' | 'script' | 'policy' | 'general'
|
||||||
|
description?: string
|
||||||
|
priority?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBaseUpdate {
|
||||||
|
name?: string
|
||||||
|
kb_type?: 'product' | 'faq' | 'script' | 'policy' | 'general'
|
||||||
|
description?: string
|
||||||
|
priority?: number
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBaseListResponse {
|
||||||
|
data: KnowledgeBase[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
docId: string
|
||||||
|
kbId: string
|
||||||
|
fileName: string
|
||||||
|
status: string
|
||||||
|
jobId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentListResponse {
|
||||||
|
data: Document[]
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexJob {
|
||||||
|
jobId: string
|
||||||
|
docId: string
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
|
progress: number
|
||||||
|
errorMsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KB_TYPE_OPTIONS = [
|
||||||
|
{ value: 'product', label: '产品知识', color: 'primary' },
|
||||||
|
{ value: 'faq', label: '常见问题', color: 'success' },
|
||||||
|
{ value: 'script', label: '话术库', color: 'warning' },
|
||||||
|
{ value: 'policy', label: '政策法规', color: 'danger' },
|
||||||
|
{ value: 'general', label: '通用知识', color: 'info' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const KB_TYPE_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
product: { label: '产品知识', color: 'primary' },
|
||||||
|
faq: { label: '常见问题', color: 'success' },
|
||||||
|
script: { label: '话术库', color: 'warning' },
|
||||||
|
policy: { label: '政策法规', color: 'danger' },
|
||||||
|
general: { label: '通用知识', color: 'info' }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
export interface PromptTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
scene: string
|
||||||
|
description?: string
|
||||||
|
is_default: boolean
|
||||||
|
published_version?: PromptVersionInfo
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptVersionInfo {
|
||||||
|
version: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptTemplateDetail {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
scene: string
|
||||||
|
description?: string
|
||||||
|
is_default: boolean
|
||||||
|
current_content?: string
|
||||||
|
variables?: PromptVariable[]
|
||||||
|
versions?: PromptVersion[]
|
||||||
|
published_version?: PromptVersionInfo
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptVersion {
|
||||||
|
version: number
|
||||||
|
content: string
|
||||||
|
status: 'draft' | 'published' | 'archived'
|
||||||
|
variables?: PromptVariable[]
|
||||||
|
created_at: string
|
||||||
|
published_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptVariable {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
default_value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptTemplateCreate {
|
||||||
|
name: string
|
||||||
|
scene: string
|
||||||
|
description?: string
|
||||||
|
content: string
|
||||||
|
is_default?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptTemplateUpdate {
|
||||||
|
name?: string
|
||||||
|
scene?: string
|
||||||
|
description?: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptTemplateListResponse {
|
||||||
|
data: PromptTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishRequest {
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RollbackRequest {
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCENE_OPTIONS = [
|
||||||
|
{ value: 'chat', label: '对话场景' },
|
||||||
|
{ value: 'qa', label: '问答场景' },
|
||||||
|
{ value: 'summary', label: '摘要场景' },
|
||||||
|
{ value: 'translation', label: '翻译场景' },
|
||||||
|
{ value: 'code', label: '代码场景' },
|
||||||
|
{ value: 'custom', label: '自定义场景' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BUILTIN_VARIABLES: PromptVariable[] = [
|
||||||
|
{ name: 'persona_name', description: 'AI 人设名称', default_value: 'AI助手' },
|
||||||
|
{ name: 'current_time', description: '当前时间' },
|
||||||
|
{ name: 'channel_type', description: '渠道类型(web/wechat/app)' },
|
||||||
|
{ name: 'user_name', description: '用户名称' },
|
||||||
|
{ name: 'context', description: '检索上下文' },
|
||||||
|
{ name: 'query', description: '用户问题' },
|
||||||
|
{ name: 'history', description: '对话历史' }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
export interface ScriptFlow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
step_count: number
|
||||||
|
is_enabled: boolean
|
||||||
|
linked_rule_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptFlowDetail {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
steps: FlowStep[]
|
||||||
|
is_enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowStep {
|
||||||
|
step_id: string
|
||||||
|
order: number
|
||||||
|
content: string
|
||||||
|
wait_for_input: boolean
|
||||||
|
timeout_seconds?: number
|
||||||
|
timeout_action?: 'repeat' | 'skip' | 'transfer'
|
||||||
|
next_conditions?: NextCondition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextCondition {
|
||||||
|
keywords: string[]
|
||||||
|
target_step_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptFlowCreate {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
steps: FlowStep[]
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptFlowUpdate {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
steps?: FlowStep[]
|
||||||
|
is_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptFlowListResponse {
|
||||||
|
data: ScriptFlow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIMEOUT_ACTION_OPTIONS = [
|
||||||
|
{ value: 'repeat', label: '重复当前步骤' },
|
||||||
|
{ value: 'skip', label: '跳过进入下一步' },
|
||||||
|
{ value: 'transfer', label: '转人工' }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
<template>
|
||||||
|
<div class="behavior-rules-tab">
|
||||||
|
<div class="tab-header">
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-select v-model="filterCategory" placeholder="按类别筛选" clearable style="width: 140px;">
|
||||||
|
<el-option v-for="opt in BEHAVIOR_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="action-section">
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加规则
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="rules" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="description" label="规则描述" min-width="300">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rule-description">
|
||||||
|
{{ row.description }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="category" label="类别" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getCategoryTagType(row.category)" size="small">
|
||||||
|
{{ getCategoryLabel(row.category) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.is_enabled"
|
||||||
|
@change="handleToggleEnabled(row)"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑规则' : '添加规则'"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||||
|
<el-form-item label="类别" prop="category">
|
||||||
|
<el-select v-model="formData.category" placeholder="请选择类别" style="width: 100%;">
|
||||||
|
<el-option v-for="opt in BEHAVIOR_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规则描述" prop="description">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
placeholder="请输入规则描述,例如: - 不承诺具体价格或优惠 - 不评价竞品 - 保持专业、友好的语气"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '添加' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
listBehaviorRules,
|
||||||
|
createBehaviorRule,
|
||||||
|
updateBehaviorRule,
|
||||||
|
deleteBehaviorRule
|
||||||
|
} from '@/api/guardrail'
|
||||||
|
import { BEHAVIOR_CATEGORY_OPTIONS } from '@/types/guardrail'
|
||||||
|
import type { BehaviorRule, BehaviorRuleCreate, BehaviorRuleUpdate } from '@/types/guardrail'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rules = ref<BehaviorRule[]>([])
|
||||||
|
const filterCategory = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const currentEditId = ref('')
|
||||||
|
|
||||||
|
const defaultFormData = (): BehaviorRuleCreate => ({
|
||||||
|
description: '',
|
||||||
|
category: 'compliance',
|
||||||
|
is_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = ref<BehaviorRuleCreate>(defaultFormData())
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
category: [{ required: true, message: '请选择类别', trigger: 'change' }],
|
||||||
|
description: [{ required: true, message: '请输入规则描述', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryLabel = (category: string) => {
|
||||||
|
const opt = BEHAVIOR_CATEGORY_OPTIONS.find(o => o.value === category)
|
||||||
|
return opt?.label || category
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryTagType = (category: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||||
|
const colorMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||||
|
compliance: 'danger',
|
||||||
|
tone: 'warning',
|
||||||
|
boundary: 'primary',
|
||||||
|
custom: 'info'
|
||||||
|
}
|
||||||
|
return colorMap[category] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRules = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listBehaviorRules({
|
||||||
|
category: filterCategory.value || undefined
|
||||||
|
})
|
||||||
|
rules.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载规则列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
currentEditId.value = ''
|
||||||
|
formData.value = defaultFormData()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: BehaviorRule) => {
|
||||||
|
isEdit.value = true
|
||||||
|
currentEditId.value = row.id
|
||||||
|
formData.value = {
|
||||||
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
|
is_enabled: row.is_enabled
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: BehaviorRule) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该规则吗?', '确认删除', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteBehaviorRule(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadRules()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (row: BehaviorRule) => {
|
||||||
|
try {
|
||||||
|
await updateBehaviorRule(row.id, { is_enabled: row.is_enabled })
|
||||||
|
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||||
|
} catch (error) {
|
||||||
|
row.is_enabled = !row.is_enabled
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
const updateData: BehaviorRuleUpdate = {
|
||||||
|
description: formData.value.description,
|
||||||
|
category: formData.value.category,
|
||||||
|
is_enabled: formData.value.is_enabled
|
||||||
|
}
|
||||||
|
await updateBehaviorRule(currentEditId.value, updateData)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
await createBehaviorRule(formData.value)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadRules()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(isEdit.value ? '保存失败' : '添加失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filterCategory, () => {
|
||||||
|
loadRules()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRules()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.behavior-rules-tab {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
<template>
|
||||||
|
<div class="forbidden-words-tab">
|
||||||
|
<div class="tab-header">
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-select v-model="filterCategory" placeholder="按类别筛选" clearable style="width: 140px;">
|
||||||
|
<el-option v-for="opt in WORD_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索禁词"
|
||||||
|
clearable
|
||||||
|
style="width: 200px;"
|
||||||
|
@keyup.enter="loadWords"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
<div class="action-section">
|
||||||
|
<el-button @click="showBatchImport = true">
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
批量导入
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加禁词
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="words" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="word" label="禁词" min-width="150" />
|
||||||
|
<el-table-column prop="category" label="类别" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getCategoryTagType(row.category)" size="small">
|
||||||
|
{{ getCategoryLabel(row.category) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="strategy" label="策略" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small">
|
||||||
|
{{ getStrategyLabel(row.strategy) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="hit_count" label="命中次数" width="100" sortable />
|
||||||
|
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.is_enabled"
|
||||||
|
@change="handleToggleEnabled(row)"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑禁词' : '添加禁词'"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||||
|
<el-form-item label="禁词" prop="word">
|
||||||
|
<el-input v-model="formData.word" placeholder="请输入禁词" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类别" prop="category">
|
||||||
|
<el-select v-model="formData.category" placeholder="请选择类别" style="width: 100%;">
|
||||||
|
<el-option v-for="opt in WORD_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="策略" prop="strategy">
|
||||||
|
<el-radio-group v-model="formData.strategy">
|
||||||
|
<el-radio-button
|
||||||
|
v-for="opt in WORD_STRATEGY_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<transition name="slide-fade" mode="out-in">
|
||||||
|
<el-form-item v-if="formData.strategy === 'replace'" label="替换文本" prop="replacement">
|
||||||
|
<el-input v-model="formData.replacement" placeholder="请输入替换文本" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else-if="formData.strategy === 'block'" label="兜底话术" prop="fallback_message">
|
||||||
|
<el-input v-model="formData.fallback_message" type="textarea" :rows="3" placeholder="请输入兜底话术" />
|
||||||
|
</el-form-item>
|
||||||
|
</transition>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '添加' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="showBatchImport"
|
||||||
|
title="批量导入禁词"
|
||||||
|
width="500px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="batchForm" label-width="80px">
|
||||||
|
<el-form-item label="默认类别">
|
||||||
|
<el-select v-model="batchForm.category" style="width: 100%;">
|
||||||
|
<el-option v-for="opt in WORD_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认策略">
|
||||||
|
<el-select v-model="batchForm.strategy" style="width: 100%;">
|
||||||
|
<el-option v-for="opt in WORD_STRATEGY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="禁词列表">
|
||||||
|
<el-input
|
||||||
|
v-model="batchForm.words"
|
||||||
|
type="textarea"
|
||||||
|
:rows="10"
|
||||||
|
placeholder="每行一个禁词,例如: 竞品A 竞品B 敏感词1"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showBatchImport = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="batchSubmitting" @click="handleBatchImport">
|
||||||
|
导入
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Upload, Search } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
listForbiddenWords,
|
||||||
|
createForbiddenWord,
|
||||||
|
updateForbiddenWord,
|
||||||
|
deleteForbiddenWord
|
||||||
|
} from '@/api/guardrail'
|
||||||
|
import { WORD_CATEGORY_OPTIONS, WORD_STRATEGY_OPTIONS } from '@/types/guardrail'
|
||||||
|
import type { ForbiddenWord, ForbiddenWordCreate, ForbiddenWordUpdate } from '@/types/guardrail'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const words = ref<ForbiddenWord[]>([])
|
||||||
|
const filterCategory = ref('')
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const showBatchImport = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const batchSubmitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const currentEditId = ref('')
|
||||||
|
|
||||||
|
const defaultFormData = (): ForbiddenWordCreate => ({
|
||||||
|
word: '',
|
||||||
|
category: 'custom',
|
||||||
|
strategy: 'mask',
|
||||||
|
replacement: '',
|
||||||
|
fallback_message: '',
|
||||||
|
is_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = ref<ForbiddenWordCreate>(defaultFormData())
|
||||||
|
|
||||||
|
const batchForm = ref({
|
||||||
|
category: 'custom',
|
||||||
|
strategy: 'mask',
|
||||||
|
words: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
word: [{ required: true, message: '请输入禁词', trigger: 'blur' }],
|
||||||
|
category: [{ required: true, message: '请选择类别', trigger: 'change' }],
|
||||||
|
strategy: [{ required: true, message: '请选择策略', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryLabel = (category: string) => {
|
||||||
|
const opt = WORD_CATEGORY_OPTIONS.find(o => o.value === category)
|
||||||
|
return opt?.label || category
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryTagType = (category: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||||
|
const colorMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||||
|
competitor: 'danger',
|
||||||
|
sensitive: 'warning',
|
||||||
|
political: 'danger',
|
||||||
|
custom: 'info'
|
||||||
|
}
|
||||||
|
return colorMap[category] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStrategyLabel = (strategy: string) => {
|
||||||
|
const opt = WORD_STRATEGY_OPTIONS.find(o => o.value === strategy)
|
||||||
|
return opt?.label || strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWords = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listForbiddenWords({
|
||||||
|
category: filterCategory.value || undefined
|
||||||
|
})
|
||||||
|
words.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载禁词列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
currentEditId.value = ''
|
||||||
|
formData.value = defaultFormData()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: ForbiddenWord) => {
|
||||||
|
isEdit.value = true
|
||||||
|
currentEditId.value = row.id
|
||||||
|
formData.value = {
|
||||||
|
word: row.word,
|
||||||
|
category: row.category,
|
||||||
|
strategy: row.strategy,
|
||||||
|
replacement: row.replacement || '',
|
||||||
|
fallback_message: row.fallback_message || '',
|
||||||
|
is_enabled: row.is_enabled
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: ForbiddenWord) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该禁词吗?', '确认删除', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteForbiddenWord(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadWords()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (row: ForbiddenWord) => {
|
||||||
|
try {
|
||||||
|
await updateForbiddenWord(row.id, { is_enabled: row.is_enabled })
|
||||||
|
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||||
|
} catch (error) {
|
||||||
|
row.is_enabled = !row.is_enabled
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
const updateData: ForbiddenWordUpdate = {
|
||||||
|
word: formData.value.word,
|
||||||
|
category: formData.value.category,
|
||||||
|
strategy: formData.value.strategy,
|
||||||
|
replacement: formData.value.replacement,
|
||||||
|
fallback_message: formData.value.fallback_message,
|
||||||
|
is_enabled: formData.value.is_enabled
|
||||||
|
}
|
||||||
|
await updateForbiddenWord(currentEditId.value, updateData)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
await createForbiddenWord(formData.value)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadWords()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(isEdit.value ? '保存失败' : '添加失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchImport = async () => {
|
||||||
|
const wordList = batchForm.value.words
|
||||||
|
.split('\n')
|
||||||
|
.map(w => w.trim())
|
||||||
|
.filter(w => w.length > 0)
|
||||||
|
|
||||||
|
if (wordList.length === 0) {
|
||||||
|
ElMessage.warning('请输入要导入的禁词')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
batchSubmitting.value = true
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const word of wordList) {
|
||||||
|
try {
|
||||||
|
await createForbiddenWord({
|
||||||
|
word,
|
||||||
|
category: batchForm.value.category as any,
|
||||||
|
strategy: batchForm.value.strategy as any,
|
||||||
|
is_enabled: true
|
||||||
|
})
|
||||||
|
successCount++
|
||||||
|
} catch {
|
||||||
|
failCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
ElMessage.success(`成功导入 ${successCount} 个禁词${failCount > 0 ? `,${failCount} 个失败` : ''}`)
|
||||||
|
showBatchImport.value = false
|
||||||
|
batchForm.value.words = ''
|
||||||
|
loadWords()
|
||||||
|
} else {
|
||||||
|
ElMessage.error('导入失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
batchSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filterCategory, () => {
|
||||||
|
loadWords()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadWords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forbidden-words-tab {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-active {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-leave-active {
|
||||||
|
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-from,
|
||||||
|
.slide-fade-leave-to {
|
||||||
|
transform: translateX(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
<template>
|
||||||
|
<div class="guardrail-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="title-section">
|
||||||
|
<h1 class="page-title">输出护栏管理</h1>
|
||||||
|
<p class="page-desc">配置禁词和行为规则,确保 AI 输出符合合规要求。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="禁词管理" name="forbidden-words">
|
||||||
|
<forbidden-words-tab />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="行为规则" name="behavior-rules">
|
||||||
|
<behavior-rules-tab />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ForbiddenWordsTab from './components/ForbiddenWordsTab.vue'
|
||||||
|
import BehaviorRulesTab from './components/BehaviorRulesTab.vue'
|
||||||
|
|
||||||
|
const activeTab = ref('forbidden-words')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.guardrail-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
<template>
|
||||||
|
<div class="keyword-input">
|
||||||
|
<el-tag
|
||||||
|
v-for="(keyword, index) in modelValue"
|
||||||
|
:key="index"
|
||||||
|
closable
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
@close="handleRemove(index)"
|
||||||
|
class="keyword-tag"
|
||||||
|
>
|
||||||
|
{{ keyword }}
|
||||||
|
</el-tag>
|
||||||
|
<el-input
|
||||||
|
v-model="inputValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
size="small"
|
||||||
|
@keyup.enter="handleAdd"
|
||||||
|
@blur="handleAdd"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputValue = ref('')
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const value = inputValue.value.trim()
|
||||||
|
if (value && !props.modelValue.includes(value)) {
|
||||||
|
emit('update:modelValue', [...props.modelValue, value])
|
||||||
|
}
|
||||||
|
inputValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
const newValue = [...props.modelValue]
|
||||||
|
newValue.splice(index, 1)
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.keyword-input {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-input:focus-within {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
width: 120px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field :deep(.el-input__wrapper) {
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field :deep(.el-input__wrapper):focus-within {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
<template>
|
||||||
|
<div class="pattern-input">
|
||||||
|
<div
|
||||||
|
v-for="(pattern, index) in modelValue"
|
||||||
|
:key="index"
|
||||||
|
class="pattern-item"
|
||||||
|
>
|
||||||
|
<code class="pattern-code">{{ pattern }}</code>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
@click="handleRemove(index)"
|
||||||
|
>
|
||||||
|
<el-icon><Close /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="input-row">
|
||||||
|
<el-input
|
||||||
|
v-model="inputValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
size="small"
|
||||||
|
@keyup.enter="handleAdd"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" size="small" @click="handleAdd">添加</el-button>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="errorMessage" class="error-tip">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Close, WarningFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputValue = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const validateRegex = (pattern: string): boolean => {
|
||||||
|
try {
|
||||||
|
new RegExp(pattern)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const value = inputValue.value.trim()
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
if (!validateRegex(value)) {
|
||||||
|
errorMessage.value = '无效的正则表达式'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
if (!props.modelValue.includes(value)) {
|
||||||
|
emit('update:modelValue', [...props.modelValue, value])
|
||||||
|
}
|
||||||
|
inputValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
const newValue = [...props.modelValue]
|
||||||
|
newValue.splice(index, 1)
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pattern-input {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-input:focus-within {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-code {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,457 @@
|
||||||
|
<template>
|
||||||
|
<div class="intent-rule-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="title-section">
|
||||||
|
<h1 class="page-title">意图规则管理</h1>
|
||||||
|
<p class="page-desc">配置意图识别规则,让特定问题走固定回复或话术流程。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select v-model="filterResponseType" placeholder="按响应类型筛选" clearable style="width: 150px;">
|
||||||
|
<el-option v-for="opt in RESPONSE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建规则
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="hover" class="rule-card" v-loading="loading">
|
||||||
|
<el-table :data="rules" stripe style="width: 100%">
|
||||||
|
<el-table-column prop="name" label="意图名称" min-width="150" />
|
||||||
|
<el-table-column prop="keywords" label="关键词" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="keyword-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="(kw, idx) in row.keywords?.slice(0, 3)"
|
||||||
|
:key="idx"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
style="margin-right: 4px; margin-bottom: 4px;"
|
||||||
|
>
|
||||||
|
{{ kw }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="row.keywords?.length > 3" class="more-tag">
|
||||||
|
+{{ row.keywords.length - 3 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="response_type" label="响应类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getResponseTagType(row.response_type)" size="small">
|
||||||
|
{{ getResponseLabel(row.response_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="priority" label="优先级" width="80" sortable />
|
||||||
|
<el-table-column prop="hit_count" label="命中次数" width="100" sortable />
|
||||||
|
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.is_enabled"
|
||||||
|
@change="handleToggleEnabled(row)"
|
||||||
|
active-color="#67C23A"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑规则' : '新建规则'"
|
||||||
|
width="700px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="意图名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="请输入意图名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="优先级" prop="priority">
|
||||||
|
<el-input-number v-model="formData.priority" :min="1" :max="100" style="width: 100%;" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<keyword-input v-model="formData.keywords" placeholder="输入关键词后按回车添加" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="正则表达式">
|
||||||
|
<pattern-input v-model="formData.patterns" placeholder="输入正则表达式后按回车添加" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="响应类型" prop="response_type">
|
||||||
|
<el-radio-group v-model="formData.response_type" @change="handleResponseTypeChange">
|
||||||
|
<el-radio-button
|
||||||
|
v-for="opt in RESPONSE_TYPE_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<transition name="slide-fade" mode="out-in">
|
||||||
|
<div v-if="formData.response_type === 'fixed'" key="fixed">
|
||||||
|
<el-form-item label="固定回复" prop="fixed_reply">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.fixed_reply"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请输入固定回复内容"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="formData.response_type === 'rag'" key="rag">
|
||||||
|
<el-form-item label="知识库" prop="target_kb_ids">
|
||||||
|
<el-select
|
||||||
|
v-model="formData.target_kb_ids"
|
||||||
|
multiple
|
||||||
|
placeholder="请选择知识库"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="kb in knowledgeBases"
|
||||||
|
:key="kb.id"
|
||||||
|
:label="kb.name"
|
||||||
|
:value="kb.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="formData.response_type === 'flow'" key="flow">
|
||||||
|
<el-form-item label="话术流程" prop="flow_id">
|
||||||
|
<el-select v-model="formData.flow_id" placeholder="请选择话术流程" style="width: 100%;">
|
||||||
|
<el-option
|
||||||
|
v-for="flow in scriptFlows"
|
||||||
|
:key="flow.id"
|
||||||
|
:label="flow.name"
|
||||||
|
:value="flow.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="formData.response_type === 'transfer'" key="transfer">
|
||||||
|
<el-form-item label="转人工话术" prop="transfer_message">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.transfer_message"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入转人工时的提示话术"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
listIntentRules,
|
||||||
|
createIntentRule,
|
||||||
|
updateIntentRule,
|
||||||
|
deleteIntentRule
|
||||||
|
} from '@/api/intent-rule'
|
||||||
|
import { listKnowledgeBases } from '@/api/knowledge-base'
|
||||||
|
import { listScriptFlows } from '@/api/script-flow'
|
||||||
|
import { RESPONSE_TYPE_OPTIONS, RESPONSE_TYPE_MAP } from '@/types/intent-rule'
|
||||||
|
import type { IntentRule, IntentRuleCreate, IntentRuleUpdate } from '@/types/intent-rule'
|
||||||
|
import type { KnowledgeBase } from '@/types/knowledge-base'
|
||||||
|
import type { ScriptFlow } from '@/types/script-flow'
|
||||||
|
import KeywordInput from './components/KeywordInput.vue'
|
||||||
|
import PatternInput from './components/PatternInput.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rules = ref<IntentRule[]>([])
|
||||||
|
const knowledgeBases = ref<KnowledgeBase[]>([])
|
||||||
|
const scriptFlows = ref<ScriptFlow[]>([])
|
||||||
|
const filterResponseType = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const currentEditId = ref('')
|
||||||
|
|
||||||
|
const defaultFormData = (): IntentRuleCreate => ({
|
||||||
|
name: '',
|
||||||
|
keywords: [],
|
||||||
|
patterns: [],
|
||||||
|
response_type: 'fixed',
|
||||||
|
priority: 50,
|
||||||
|
fixed_reply: '',
|
||||||
|
target_kb_ids: [],
|
||||||
|
flow_id: '',
|
||||||
|
transfer_message: '',
|
||||||
|
is_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = ref<IntentRuleCreate>(defaultFormData())
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [{ required: true, message: '请输入意图名称', trigger: 'blur' }],
|
||||||
|
response_type: [{ required: true, message: '请选择响应类型', trigger: 'change' }],
|
||||||
|
priority: [{ required: true, message: '请设置优先级', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResponseLabel = (type: string) => {
|
||||||
|
return RESPONSE_TYPE_MAP[type]?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResponseTagType = (type: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||||
|
const colorMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||||
|
fixed: 'primary',
|
||||||
|
rag: 'success',
|
||||||
|
flow: 'warning',
|
||||||
|
transfer: 'danger'
|
||||||
|
}
|
||||||
|
return colorMap[type] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRules = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listIntentRules({
|
||||||
|
response_type: filterResponseType.value || undefined
|
||||||
|
})
|
||||||
|
rules.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载规则列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadKnowledgeBases = async () => {
|
||||||
|
try {
|
||||||
|
const res = await listKnowledgeBases({ is_enabled: true })
|
||||||
|
knowledgeBases.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载知识库列表失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadScriptFlows = async () => {
|
||||||
|
try {
|
||||||
|
const res = await listScriptFlows({ is_enabled: true })
|
||||||
|
scriptFlows.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载话术流程列表失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
currentEditId.value = ''
|
||||||
|
formData.value = defaultFormData()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: IntentRule) => {
|
||||||
|
isEdit.value = true
|
||||||
|
currentEditId.value = row.id
|
||||||
|
formData.value = {
|
||||||
|
name: row.name,
|
||||||
|
keywords: row.keywords || [],
|
||||||
|
patterns: row.patterns || [],
|
||||||
|
response_type: row.response_type,
|
||||||
|
priority: row.priority,
|
||||||
|
fixed_reply: row.fixed_reply || '',
|
||||||
|
target_kb_ids: row.target_kb_ids || [],
|
||||||
|
flow_id: row.flow_id || '',
|
||||||
|
transfer_message: row.transfer_message || '',
|
||||||
|
is_enabled: row.is_enabled
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: IntentRule) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该规则吗?', '确认删除', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteIntentRule(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadRules()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (row: IntentRule) => {
|
||||||
|
try {
|
||||||
|
await updateIntentRule(row.id, { is_enabled: row.is_enabled })
|
||||||
|
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||||
|
} catch (error) {
|
||||||
|
row.is_enabled = !row.is_enabled
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResponseTypeChange = () => {
|
||||||
|
formData.value.fixed_reply = ''
|
||||||
|
formData.value.target_kb_ids = []
|
||||||
|
formData.value.flow_id = ''
|
||||||
|
formData.value.transfer_message = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
const updateData: IntentRuleUpdate = {
|
||||||
|
name: formData.value.name,
|
||||||
|
keywords: formData.value.keywords,
|
||||||
|
patterns: formData.value.patterns,
|
||||||
|
response_type: formData.value.response_type,
|
||||||
|
priority: formData.value.priority,
|
||||||
|
is_enabled: formData.value.is_enabled
|
||||||
|
}
|
||||||
|
if (formData.value.response_type === 'fixed') {
|
||||||
|
updateData.fixed_reply = formData.value.fixed_reply
|
||||||
|
} else if (formData.value.response_type === 'rag') {
|
||||||
|
updateData.target_kb_ids = formData.value.target_kb_ids
|
||||||
|
} else if (formData.value.response_type === 'flow') {
|
||||||
|
updateData.flow_id = formData.value.flow_id
|
||||||
|
} else if (formData.value.response_type === 'transfer') {
|
||||||
|
updateData.transfer_message = formData.value.transfer_message
|
||||||
|
}
|
||||||
|
await updateIntentRule(currentEditId.value, updateData)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
await createIntentRule(formData.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadRules()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filterResponseType, () => {
|
||||||
|
loadRules()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRules()
|
||||||
|
loadKnowledgeBases()
|
||||||
|
loadScriptFlows()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.intent-rule-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-active {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-leave-active {
|
||||||
|
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-from,
|
||||||
|
.slide-fade-leave-to {
|
||||||
|
transform: translateX(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
<template>
|
||||||
|
<div class="document-list">
|
||||||
|
<div class="list-header">
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:action="uploadUrl"
|
||||||
|
:headers="uploadHeaders"
|
||||||
|
:data="{ kb_id: kbId }"
|
||||||
|
:show-file-list="false"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:on-success="handleUploadSuccess"
|
||||||
|
:on-error="handleUploadError"
|
||||||
|
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx"
|
||||||
|
>
|
||||||
|
<el-button type="primary">
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
上传文档
|
||||||
|
</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="documents" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="fileName" label="文件名" min-width="200" />
|
||||||
|
<el-table-column prop="status" label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)" size="small">
|
||||||
|
{{ getStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="上传时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper" v-if="pagination.total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="pagination.pageSize"
|
||||||
|
:total="pagination.total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
@current-change="loadDocuments"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Upload } from '@element-plus/icons-vue'
|
||||||
|
import { listDocuments, deleteDocument, getIndexJob } from '@/api/knowledge-base'
|
||||||
|
import type { Document, IndexJob } from '@/types/knowledge-base'
|
||||||
|
import { useTenantStore } from '@/stores/tenant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
kbId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'upload-success'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const documents = ref<Document[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadUrl = computed(() => {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||||
|
return `${baseUrl}/admin/kb/documents`
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadHeaders = computed(() => ({
|
||||||
|
'X-Tenant-Id': tenantStore.currentTenantId
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getStatusType = (status: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||||
|
const typeMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||||
|
pending: 'info',
|
||||||
|
processing: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
failed: 'danger'
|
||||||
|
}
|
||||||
|
return typeMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
processing: '处理中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败'
|
||||||
|
}
|
||||||
|
return labelMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDocuments = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listDocuments({
|
||||||
|
kb_id: props.kbId,
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pagination.value.pageSize
|
||||||
|
})
|
||||||
|
documents.value = res.data || []
|
||||||
|
pagination.value = res.pagination
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载文档列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeUpload = (file: File) => {
|
||||||
|
const allowedTypes = [
|
||||||
|
'text/plain',
|
||||||
|
'text/markdown',
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
]
|
||||||
|
const allowedExtensions = ['.txt', '.md', '.pdf', '.doc', '.docx', '.xls', '.xlsx']
|
||||||
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||||
|
|
||||||
|
if (!allowedExtensions.includes(ext)) {
|
||||||
|
ElMessage.error('不支持的文件格式')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 50 * 1024 * 1024
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
ElMessage.error('文件大小不能超过 50MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadSuccess = (response: any) => {
|
||||||
|
if (response.jobId) {
|
||||||
|
ElMessage.success('文档上传成功,正在处理中...')
|
||||||
|
emit('upload-success')
|
||||||
|
loadDocuments()
|
||||||
|
pollJobStatus(response.jobId)
|
||||||
|
} else {
|
||||||
|
ElMessage.error('上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadError = () => {
|
||||||
|
ElMessage.error('上传失败,请重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollJobStatus = async (jobId: string) => {
|
||||||
|
const maxPolls = 60
|
||||||
|
let pollCount = 0
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (pollCount >= maxPolls) return
|
||||||
|
pollCount++
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job: IndexJob = await getIndexJob(jobId)
|
||||||
|
if (job.status === 'completed') {
|
||||||
|
ElMessage.success('文档处理完成')
|
||||||
|
loadDocuments()
|
||||||
|
} else if (job.status === 'failed') {
|
||||||
|
ElMessage.error(`文档处理失败: ${job.errorMsg || '未知错误'}`)
|
||||||
|
loadDocuments()
|
||||||
|
} else {
|
||||||
|
setTimeout(poll, 3000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('轮询任务状态失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(poll, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: Document) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该文档吗?', '确认删除', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteDocument(row.docId)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadDocuments()
|
||||||
|
emit('upload-success')
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDocuments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.document-list {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
<template>
|
||||||
|
<div class="knowledge-base-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="title-section">
|
||||||
|
<h1 class="page-title">知识库管理</h1>
|
||||||
|
<p class="page-desc">创建和管理多个知识库,按类型分类管理文档。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select v-model="filterType" placeholder="按类型筛选" clearable style="width: 140px;">
|
||||||
|
<el-option v-for="opt in KB_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建知识库
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kb-grid" v-loading="loading">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="kb in knowledgeBases" :key="kb.id">
|
||||||
|
<el-card shadow="hover" class="kb-card" @click="handleViewKb(kb)">
|
||||||
|
<div class="kb-header">
|
||||||
|
<div class="kb-type-badge" :style="{ backgroundColor: getTypeColor(kb.kbType) }">
|
||||||
|
{{ getTypeLabel(kb.kbType) }}
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
v-model="kb.isEnabled"
|
||||||
|
@click.stop
|
||||||
|
@change="handleToggleEnabled(kb)"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="kb-name">{{ kb.name }}</h3>
|
||||||
|
<p class="kb-desc">{{ kb.description || '暂无描述' }}</p>
|
||||||
|
<div class="kb-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>{{ kb.docCount }} 文档</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><Rank /></el-icon>
|
||||||
|
<span>优先级 {{ kb.priority }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kb-actions" @click.stop>
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(kb)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(kb)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-empty v-if="!loading && knowledgeBases.length === 0" description="暂无知识库" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑知识库' : '新建知识库'"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="请输入知识库名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="kb_type">
|
||||||
|
<el-select v-model="formData.kb_type" placeholder="请选择类型" style="width: 100%;">
|
||||||
|
<el-option v-for="opt in KB_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入描述(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="优先级">
|
||||||
|
<el-input-number v-model="formData.priority" :min="1" :max="100" style="width: 100%;" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="documentDrawer"
|
||||||
|
:title="currentKb?.name || '文档管理'"
|
||||||
|
size="70%"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<document-list
|
||||||
|
v-if="currentKb"
|
||||||
|
:kb-id="currentKb.id"
|
||||||
|
@upload-success="handleUploadSuccess"
|
||||||
|
/>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete, Document, Rank } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
listKnowledgeBases,
|
||||||
|
createKnowledgeBase,
|
||||||
|
updateKnowledgeBase,
|
||||||
|
deleteKnowledgeBase
|
||||||
|
} from '@/api/knowledge-base'
|
||||||
|
import { KB_TYPE_OPTIONS, KB_TYPE_MAP } from '@/types/knowledge-base'
|
||||||
|
import type { KnowledgeBase, KnowledgeBaseCreate, KnowledgeBaseUpdate } from '@/types/knowledge-base'
|
||||||
|
import DocumentList from './components/DocumentList.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const knowledgeBases = ref<KnowledgeBase[]>([])
|
||||||
|
const filterType = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const documentDrawer = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const currentKb = ref<KnowledgeBase | null>(null)
|
||||||
|
const currentEditId = ref('')
|
||||||
|
|
||||||
|
const defaultFormData = (): KnowledgeBaseCreate => ({
|
||||||
|
name: '',
|
||||||
|
kb_type: 'general',
|
||||||
|
description: '',
|
||||||
|
priority: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = ref<KnowledgeBaseCreate>(defaultFormData())
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }],
|
||||||
|
kb_type: [{ required: true, message: '请选择类型', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
return KB_TYPE_MAP[type]?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
product: '#409EFF',
|
||||||
|
faq: '#67C23A',
|
||||||
|
script: '#E6A23C',
|
||||||
|
policy: '#F56C6C',
|
||||||
|
general: '#909399'
|
||||||
|
}
|
||||||
|
return colorMap[type] || '#909399'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadKnowledgeBases = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listKnowledgeBases({
|
||||||
|
kb_type: filterType.value || undefined
|
||||||
|
})
|
||||||
|
knowledgeBases.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载知识库列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
currentEditId.value = ''
|
||||||
|
formData.value = defaultFormData()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (kb: KnowledgeBase) => {
|
||||||
|
isEdit.value = true
|
||||||
|
currentEditId.value = kb.id
|
||||||
|
formData.value = {
|
||||||
|
name: kb.name,
|
||||||
|
kb_type: kb.kbType as any,
|
||||||
|
description: kb.description || '',
|
||||||
|
priority: kb.priority
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (kb: KnowledgeBase) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'删除知识库将同时删除所有关联文档和索引数据,确定要删除吗?',
|
||||||
|
'确认删除',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await deleteKnowledgeBase(kb.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadKnowledgeBases()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (kb: KnowledgeBase) => {
|
||||||
|
try {
|
||||||
|
await updateKnowledgeBase(kb.id, { is_enabled: kb.isEnabled })
|
||||||
|
ElMessage.success(kb.isEnabled ? '已启用' : '已禁用')
|
||||||
|
} catch (error) {
|
||||||
|
kb.isEnabled = !kb.isEnabled
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewKb = (kb: KnowledgeBase) => {
|
||||||
|
currentKb.value = kb
|
||||||
|
documentDrawer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
const updateData: KnowledgeBaseUpdate = {
|
||||||
|
name: formData.value.name,
|
||||||
|
kb_type: formData.value.kb_type,
|
||||||
|
description: formData.value.description,
|
||||||
|
priority: formData.value.priority
|
||||||
|
}
|
||||||
|
await updateKnowledgeBase(currentEditId.value, updateData)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
await createKnowledgeBase(formData.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadKnowledgeBases()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadSuccess = () => {
|
||||||
|
loadKnowledgeBases()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filterType, () => {
|
||||||
|
loadKnowledgeBases()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadKnowledgeBases()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.knowledge-base-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-grid {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-type-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-name {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-desc {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .el-icon {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
<template>
|
||||||
|
<div class="template-detail">
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="模板名称">{{ template.name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="场景">
|
||||||
|
<el-tag size="small">{{ getSceneLabel(template.scene) }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="描述" :span="2">{{ template.description || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDate(template.created_at) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatDate(template.updated_at) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section" v-if="template.current_content">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>当前内容</h3>
|
||||||
|
<el-button type="primary" size="small" @click="handlePublishCurrent" v-if="template.published_version">
|
||||||
|
发布此版本
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="content-preview">
|
||||||
|
<pre>{{ template.current_content }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section" v-if="template.variables && template.variables.length > 0">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>变量定义</h3>
|
||||||
|
</div>
|
||||||
|
<el-table :data="template.variables" size="small" border>
|
||||||
|
<el-table-column prop="name" label="变量名" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code class="var-code">{{ '{{' + row.name + '}}' }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="描述" />
|
||||||
|
<el-table-column prop="default_value" label="默认值" width="150" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section" v-if="template.versions && template.versions.length > 0">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>版本历史</h3>
|
||||||
|
</div>
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="version in template.versions"
|
||||||
|
:key="version.version"
|
||||||
|
:type="getVersionTimelineType(version.status)"
|
||||||
|
:timestamp="formatDate(version.created_at)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<div class="version-card">
|
||||||
|
<div class="version-header">
|
||||||
|
<span class="version-number">v{{ version.version }}</span>
|
||||||
|
<el-tag :type="getVersionStatusType(version.status)" size="small">
|
||||||
|
{{ getVersionStatusLabel(version.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="version-content" v-if="version.content">
|
||||||
|
<pre>{{ truncateContent(version.content) }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="version-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
@click="handlePublish(version.version)"
|
||||||
|
v-if="version.status !== 'published'"
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
@click="handleRollback(version.version)"
|
||||||
|
v-if="version.status === 'archived'"
|
||||||
|
>
|
||||||
|
回滚到此版本
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SCENE_OPTIONS } from '@/types/prompt-template'
|
||||||
|
import type { PromptTemplateDetail, PromptVersion } from '@/types/prompt-template'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
template: PromptTemplateDetail
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'publish', version: number): void
|
||||||
|
(e: 'rollback', version: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const getSceneLabel = (scene: string) => {
|
||||||
|
const opt = SCENE_OPTIONS.find(o => o.value === scene)
|
||||||
|
return opt?.label || scene
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVersionTimelineType = (status: string): 'primary' | 'success' | 'info' | 'warning' | 'danger' => {
|
||||||
|
const typeMap: Record<string, 'primary' | 'success' | 'info' | 'warning' | 'danger'> = {
|
||||||
|
draft: 'info',
|
||||||
|
published: 'success',
|
||||||
|
archived: 'warning'
|
||||||
|
}
|
||||||
|
return typeMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVersionStatusType = (status: string): '' | 'success' | 'warning' | 'info' | 'danger' => {
|
||||||
|
const typeMap: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||||
|
draft: 'info',
|
||||||
|
published: 'success',
|
||||||
|
archived: 'warning'
|
||||||
|
}
|
||||||
|
return typeMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVersionStatusLabel = (status: string) => {
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
draft: '草稿',
|
||||||
|
published: '已发布',
|
||||||
|
archived: '历史版本'
|
||||||
|
}
|
||||||
|
return labelMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncateContent = (content: string, maxLength = 200) => {
|
||||||
|
if (content.length <= maxLength) return content
|
||||||
|
return content.substring(0, maxLength) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePublishCurrent = () => {
|
||||||
|
if (props.template.published_version) {
|
||||||
|
emit('publish', props.template.published_version.version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePublish = (version: number) => {
|
||||||
|
emit('publish', version)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRollback = (version: number) => {
|
||||||
|
emit('rollback', version)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-detail {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-preview {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-preview pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.var-code {
|
||||||
|
background-color: var(--el-fill-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-card {
|
||||||
|
background-color: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-content {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-content pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,520 @@
|
||||||
|
<template>
|
||||||
|
<div class="prompt-template-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="title-section">
|
||||||
|
<h1 class="page-title">Prompt 模板管理</h1>
|
||||||
|
<p class="page-desc">管理不同场景的 Prompt 模板,支持版本管理和一键回滚。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 150px;">
|
||||||
|
<el-option v-for="opt in SCENE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建模板
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="hover" class="template-card" v-loading="loading">
|
||||||
|
<el-table :data="templates" stripe style="width: 100%">
|
||||||
|
<el-table-column prop="name" label="模板名称" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="template-name">
|
||||||
|
<span class="name-text">{{ row.name }}</span>
|
||||||
|
<el-tag v-if="row.is_default" type="success" size="small" style="margin-left: 8px;">默认</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="scene" label="场景" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="getSceneTagType(row.scene)">
|
||||||
|
{{ getSceneLabel(row.scene) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="published_version" label="发布版本" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.published_version" class="version-badge">
|
||||||
|
v{{ row.published_version.version }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="no-version">未发布</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.updated_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" link size="small" @click="handlePublish(row)" v-if="row.published_version">
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
发布
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" link size="small" @click="handleViewDetail(row)">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑模板' : '新建模板'"
|
||||||
|
width="900px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="模板名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="请输入模板名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="场景" prop="scene">
|
||||||
|
<el-select v-model="formData.scene" placeholder="请选择场景" style="width: 100%;">
|
||||||
|
<el-option v-for="opt in SCENE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="系统指令" prop="content">
|
||||||
|
<div class="content-editor">
|
||||||
|
<div class="editor-main">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="12"
|
||||||
|
placeholder="请输入系统指令内容,支持 {{variable}} 变量语法"
|
||||||
|
class="content-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="variables-panel">
|
||||||
|
<div class="panel-title">可用变量</div>
|
||||||
|
<div class="variable-list">
|
||||||
|
<div
|
||||||
|
v-for="v in BUILTIN_VARIABLES"
|
||||||
|
:key="v.name"
|
||||||
|
class="variable-item"
|
||||||
|
@click="insertVariable(v.name)"
|
||||||
|
>
|
||||||
|
<span class="var-name">{{ '{{' + v.name + '}}' }}</span>
|
||||||
|
<span class="var-desc">{{ v.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer v-model="detailDrawer" title="模板详情" size="600px" destroy-on-close>
|
||||||
|
<template-detail
|
||||||
|
v-if="currentTemplate"
|
||||||
|
:template="currentTemplate"
|
||||||
|
@publish="handlePublishVersion"
|
||||||
|
@rollback="handleRollback"
|
||||||
|
/>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete, Upload, View } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
listPromptTemplates,
|
||||||
|
createPromptTemplate,
|
||||||
|
updatePromptTemplate,
|
||||||
|
deletePromptTemplate,
|
||||||
|
getPromptTemplate,
|
||||||
|
publishPromptTemplate,
|
||||||
|
rollbackPromptTemplate
|
||||||
|
} from '@/api/prompt-template'
|
||||||
|
import { SCENE_OPTIONS, BUILTIN_VARIABLES } from '@/types/prompt-template'
|
||||||
|
import type { PromptTemplate, PromptTemplateDetail, PromptTemplateCreate, PromptTemplateUpdate } from '@/types/prompt-template'
|
||||||
|
import TemplateDetail from './components/TemplateDetail.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const templates = ref<PromptTemplate[]>([])
|
||||||
|
const filterScene = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const detailDrawer = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const currentTemplate = ref<PromptTemplateDetail | null>(null)
|
||||||
|
const currentEditId = ref('')
|
||||||
|
|
||||||
|
const formData = ref<PromptTemplateCreate>({
|
||||||
|
name: '',
|
||||||
|
scene: '',
|
||||||
|
description: '',
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||||
|
scene: [{ required: true, message: '请选择场景', trigger: 'change' }],
|
||||||
|
content: [{ required: true, message: '请输入系统指令内容', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSceneLabel = (scene: string) => {
|
||||||
|
const opt = SCENE_OPTIONS.find(o => o.value === scene)
|
||||||
|
return opt?.label || scene
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSceneTagType = (scene: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||||
|
const typeMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||||
|
chat: 'primary',
|
||||||
|
qa: 'success',
|
||||||
|
summary: 'warning',
|
||||||
|
translation: 'danger',
|
||||||
|
code: 'info',
|
||||||
|
custom: ''
|
||||||
|
}
|
||||||
|
return typeMap[scene] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listPromptTemplates({ scene: filterScene.value || undefined })
|
||||||
|
templates.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载模板列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
currentEditId.value = ''
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
scene: '',
|
||||||
|
description: '',
|
||||||
|
content: ''
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (row: PromptTemplate) => {
|
||||||
|
isEdit.value = true
|
||||||
|
currentEditId.value = row.id
|
||||||
|
try {
|
||||||
|
const detail = await getPromptTemplate(row.id)
|
||||||
|
formData.value = {
|
||||||
|
name: detail.name,
|
||||||
|
scene: detail.scene,
|
||||||
|
description: detail.description || '',
|
||||||
|
content: detail.current_content || ''
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载模板详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: PromptTemplate) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该模板吗?删除后无法恢复。', '确认删除', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deletePromptTemplate(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePublish = async (row: PromptTemplate) => {
|
||||||
|
if (!row.published_version) {
|
||||||
|
ElMessage.warning('该模板暂无可发布版本')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要发布版本 v${row.published_version.version} 吗?`, '确认发布', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'info'
|
||||||
|
})
|
||||||
|
await publishPromptTemplate(row.id, { version: row.published_version.version })
|
||||||
|
ElMessage.success('发布成功')
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('发布失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewDetail = async (row: PromptTemplate) => {
|
||||||
|
try {
|
||||||
|
currentTemplate.value = await getPromptTemplate(row.id)
|
||||||
|
detailDrawer.value = true
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载模板详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
const updateData: PromptTemplateUpdate = {
|
||||||
|
name: formData.value.name,
|
||||||
|
scene: formData.value.scene,
|
||||||
|
description: formData.value.description,
|
||||||
|
content: formData.value.content
|
||||||
|
}
|
||||||
|
await updatePromptTemplate(currentEditId.value, updateData)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
await createPromptTemplate(formData.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertVariable = (varName: string) => {
|
||||||
|
const textarea = document.querySelector('.content-textarea textarea') as HTMLTextAreaElement
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const text = formData.value.content || ''
|
||||||
|
const insertText = `{{${varName}}}`
|
||||||
|
formData.value.content = text.substring(0, start) + insertText + text.substring(end)
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(start + insertText.length, start + insertText.length)
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
formData.value.content = (formData.value.content || '') + `{{${varName}}}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePublishVersion = async (version: number) => {
|
||||||
|
if (!currentTemplate.value) return
|
||||||
|
try {
|
||||||
|
await publishPromptTemplate(currentTemplate.value.id, { version })
|
||||||
|
ElMessage.success('发布成功')
|
||||||
|
currentTemplate.value = await getPromptTemplate(currentTemplate.value.id)
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('发布失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRollback = async (version: number) => {
|
||||||
|
if (!currentTemplate.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要回滚到版本 v${version} 吗?`, '确认回滚', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await rollbackPromptTemplate(currentTemplate.value.id, { version })
|
||||||
|
ElMessage.success('回滚成功')
|
||||||
|
currentTemplate.value = await getPromptTemplate(currentTemplate.value.id)
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('回滚失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filterScene, () => {
|
||||||
|
loadTemplates()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTemplates()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prompt-template-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background-color: var(--el-color-success-light-9);
|
||||||
|
color: var(--el-color-success);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-version {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-editor {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variables-panel {
|
||||||
|
width: 200px;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-item:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.var-name {
|
||||||
|
display: block;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.var-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
<template>
|
||||||
|
<div class="flow-preview">
|
||||||
|
<div class="flow-info">
|
||||||
|
<h3>{{ flow.name }}</h3>
|
||||||
|
<p v-if="flow.description">{{ flow.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="(step, index) in flow.steps"
|
||||||
|
:key="step.step_id"
|
||||||
|
:type="getStepType(index)"
|
||||||
|
:size="index === currentStep ? 'large' : 'normal'"
|
||||||
|
:hollow="index !== currentStep"
|
||||||
|
>
|
||||||
|
<div class="step-card" :class="{ active: index === currentStep }">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-number">步骤 {{ index + 1 }}</span>
|
||||||
|
<el-tag v-if="step.wait_for_input" size="small" type="info">等待输入</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<pre>{{ step.content }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="step-config" v-if="step.wait_for_input">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">超时时间:</span>
|
||||||
|
<span class="value">{{ step.timeout_seconds }}秒</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">超时动作:</span>
|
||||||
|
<span class="value">{{ getTimeoutActionLabel(step.timeout_action) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
|
||||||
|
<div class="preview-controls" v-if="flow.steps.length > 0">
|
||||||
|
<el-button :disabled="currentStep <= 0" @click="prevStep">上一步</el-button>
|
||||||
|
<span class="step-indicator">{{ currentStep + 1 }} / {{ flow.steps.length }}</span>
|
||||||
|
<el-button :disabled="currentStep >= flow.steps.length - 1" @click="nextStep">下一步</el-button>
|
||||||
|
<el-button type="primary" @click="resetPreview">重置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
||||||
|
import type { ScriptFlowDetail } from '@/types/script-flow'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flow: ScriptFlowDetail
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentStep = ref(0)
|
||||||
|
|
||||||
|
const getStepType = (index: number): 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||||
|
if (index === currentStep.value) return 'primary'
|
||||||
|
if (index < currentStep.value) return 'success'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeoutActionLabel = (action?: string) => {
|
||||||
|
if (!action) return '-'
|
||||||
|
const opt = TIMEOUT_ACTION_OPTIONS.find(o => o.value === action)
|
||||||
|
return opt?.label || action
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
currentStep.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (currentStep.value < props.flow.steps.length - 1) {
|
||||||
|
currentStep.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPreview = () => {
|
||||||
|
currentStep.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flow-preview {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-info {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-info h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-info p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card {
|
||||||
|
background-color: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card.active {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-config {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: var(--el-fill-color-lighter);
|
||||||
|
border-top: 1px solid var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item .label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item .value {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
<template>
|
||||||
|
<div class="script-flow-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="title-section">
|
||||||
|
<h1 class="page-title">话术流程管理</h1>
|
||||||
|
<p class="page-desc">编排多步骤的话术流程,引导用户按固定步骤完成信息收集。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建流程
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="hover" class="flow-card" v-loading="loading">
|
||||||
|
<el-table :data="flows" stripe style="width: 100%">
|
||||||
|
<el-table-column prop="name" label="流程名称" min-width="180" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.description || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="step_count" label="步骤数" width="100" />
|
||||||
|
<el-table-column prop="linked_rule_count" label="关联规则" width="100" />
|
||||||
|
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.is_enabled"
|
||||||
|
@change="handleToggleEnabled(row)"
|
||||||
|
active-color="#67C23A"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.updated_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="info" link size="small" @click="handlePreview(row)">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
预览
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑流程' : '新建流程'"
|
||||||
|
width="900px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="流程名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="请输入流程名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="启用状态">
|
||||||
|
<el-switch v-model="formData.is_enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">步骤配置</el-divider>
|
||||||
|
|
||||||
|
<div class="steps-editor">
|
||||||
|
<draggable
|
||||||
|
v-model="formData.steps"
|
||||||
|
item-key="step_id"
|
||||||
|
handle=".drag-handle"
|
||||||
|
animation="200"
|
||||||
|
>
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="drag-handle">
|
||||||
|
<el-icon><Rank /></el-icon>
|
||||||
|
</div>
|
||||||
|
<span class="step-order">步骤 {{ index + 1 }}</span>
|
||||||
|
<el-button type="danger" link size="small" @click="removeStep(index)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<el-form-item label="话术内容">
|
||||||
|
<el-input
|
||||||
|
v-model="element.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入话术内容,支持 {{variable}} 占位符"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="等待输入">
|
||||||
|
<el-switch v-model="element.wait_for_input" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8" v-if="element.wait_for_input">
|
||||||
|
<el-form-item label="超时(秒)">
|
||||||
|
<el-input-number v-model="element.timeout_seconds" :min="5" :max="300" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8" v-if="element.wait_for_input">
|
||||||
|
<el-form-item label="超时动作">
|
||||||
|
<el-select v-model="element.timeout_action" style="width: 100%;">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in TIMEOUT_ACTION_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<el-button type="primary" plain @click="addStep" style="width: 100%; margin-top: 12px;">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加步骤
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer v-model="previewDrawer" title="流程预览" size="500px" destroy-on-close>
|
||||||
|
<flow-preview v-if="currentFlow" :flow="currentFlow" />
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete, View, Rank } from '@element-plus/icons-vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
import {
|
||||||
|
listScriptFlows,
|
||||||
|
createScriptFlow,
|
||||||
|
updateScriptFlow,
|
||||||
|
deleteScriptFlow,
|
||||||
|
getScriptFlow
|
||||||
|
} from '@/api/script-flow'
|
||||||
|
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
||||||
|
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep } from '@/types/script-flow'
|
||||||
|
import FlowPreview from './components/FlowPreview.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const flows = ref<ScriptFlow[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const previewDrawer = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const currentFlow = ref<ScriptFlowDetail | null>(null)
|
||||||
|
const currentEditId = ref('')
|
||||||
|
|
||||||
|
const generateStepId = () => `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
|
const defaultFormData = (): ScriptFlowCreate => ({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
steps: [],
|
||||||
|
is_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = ref<ScriptFlowCreate>(defaultFormData())
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFlows = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listScriptFlows()
|
||||||
|
flows.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载流程列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
currentEditId.value = ''
|
||||||
|
formData.value = defaultFormData()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (row: ScriptFlow) => {
|
||||||
|
isEdit.value = true
|
||||||
|
currentEditId.value = row.id
|
||||||
|
try {
|
||||||
|
const detail = await getScriptFlow(row.id)
|
||||||
|
formData.value = {
|
||||||
|
name: detail.name,
|
||||||
|
description: detail.description || '',
|
||||||
|
steps: detail.steps || [],
|
||||||
|
is_enabled: detail.is_enabled
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载流程详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: ScriptFlow) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该流程吗?', '确认删除', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteScriptFlow(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadFlows()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (row: ScriptFlow) => {
|
||||||
|
try {
|
||||||
|
await updateScriptFlow(row.id, { is_enabled: row.is_enabled })
|
||||||
|
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||||
|
} catch (error) {
|
||||||
|
row.is_enabled = !row.is_enabled
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview = async (row: ScriptFlow) => {
|
||||||
|
try {
|
||||||
|
currentFlow.value = await getScriptFlow(row.id)
|
||||||
|
previewDrawer.value = true
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载流程详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStep = () => {
|
||||||
|
formData.value.steps.push({
|
||||||
|
step_id: generateStepId(),
|
||||||
|
order: formData.value.steps.length + 1,
|
||||||
|
content: '',
|
||||||
|
wait_for_input: true,
|
||||||
|
timeout_seconds: 30,
|
||||||
|
timeout_action: 'repeat',
|
||||||
|
next_conditions: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeStep = (index: number) => {
|
||||||
|
formData.value.steps.splice(index, 1)
|
||||||
|
formData.value.steps.forEach((step, i) => {
|
||||||
|
step.order = i + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.value.steps.length === 0) {
|
||||||
|
ElMessage.warning('请至少添加一个步骤')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
...formData.value,
|
||||||
|
steps: formData.value.steps.map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
order: index + 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
const updateData: ScriptFlowUpdate = submitData
|
||||||
|
await updateScriptFlow(currentEditId.value, updateData)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} else {
|
||||||
|
await createScriptFlow(submitData)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadFlows()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFlows()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.script-flow-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-editor {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--el-fill-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: move;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-order {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -4,7 +4,7 @@ feature: ASA
|
||||||
status: in-progress
|
status: in-progress
|
||||||
created: 2026-02-24
|
created: 2026-02-24
|
||||||
last_updated: "2026-02-27"
|
last_updated: "2026-02-27"
|
||||||
version: "0.6.0"
|
version: "0.7.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
# AI 中台管理界面(ai-service-admin)进度文档
|
# AI 中台管理界面(ai-service-admin)进度文档
|
||||||
|
|
@ -13,7 +13,7 @@ version: "0.6.0"
|
||||||
|
|
||||||
- **module**: ai-service-admin
|
- **module**: ai-service-admin
|
||||||
- **feature**: ASA
|
- **feature**: ASA
|
||||||
- **status**: 🔄 进行中 (Phase 8-12 待实现)
|
- **status**: 🔄 进行中 (Phase 8-12 前端页面已实现)
|
||||||
|
|
||||||
## spec_references
|
## spec_references
|
||||||
|
|
||||||
|
|
@ -31,25 +31,25 @@ version: "0.6.0"
|
||||||
- [x] Phase 5: 后端管理接口实现 (100%) [Backend Admin APIs]
|
- [x] Phase 5: 后端管理接口实现 (100%) [Backend Admin APIs]
|
||||||
- [x] Phase 6: 嵌入模型管理 (100%) [P5-01 ~ P5-08]
|
- [x] Phase 6: 嵌入模型管理 (100%) [P5-01 ~ P5-08]
|
||||||
- [x] Phase 7: LLM 配置与 RAG 调试输出 (100%) [P6-01 ~ P6-10]
|
- [x] Phase 7: LLM 配置与 RAG 调试输出 (100%) [P6-01 ~ P6-10]
|
||||||
- [ ] Phase 8: Prompt 模板管理 (0%) [P8-01 ~ P8-06] ⏳
|
- [x] Phase 8: Prompt 模板管理 (100%) [P8-01 ~ P8-06] ✅
|
||||||
- [ ] Phase 9: 多知识库管理 (0%) [P9-01 ~ P9-06] ⏳
|
- [x] Phase 9: 多知识库管理 (100%) [P9-01 ~ P9-06] ✅
|
||||||
- [ ] Phase 10: 意图规则管理 (0%) [P10-01 ~ P10-06] ⏳ (后端 API 已完成)
|
- [x] Phase 10: 意图规则管理 (100%) [P10-01 ~ P10-06] ✅
|
||||||
- [ ] Phase 11: 话术流程管理 (0%) [P11-01 ~ P11-05] ⏳
|
- [x] Phase 11: 话术流程管理 (100%) [P11-01 ~ P11-05] ✅
|
||||||
- [ ] Phase 12: 输出护栏管理 (0%) [P12-01 ~ P12-07] ⏳
|
- [x] Phase 12: 输出护栏管理 (100%) [P12-01 ~ P12-07] ✅
|
||||||
|
|
||||||
## current_phase
|
## current_phase
|
||||||
|
|
||||||
**goal**: 🔄 Phase 8-12 待实现(后端 API 已部分完成)
|
**goal**: ✅ Phase 8-12 前端页面已实现完成
|
||||||
|
|
||||||
### backend_api_status
|
### backend_api_status
|
||||||
|
|
||||||
| Phase | 后端 API 状态 | 前端页面状态 |
|
| Phase | 后端 API 状态 | 前端页面状态 |
|
||||||
|-------|--------------|--------------|
|
|-------|--------------|--------------|
|
||||||
| Phase 8: Prompt 模板管理 | ✅ 已完成 | ⏳ 待实现 |
|
| Phase 8: Prompt 模板管理 | ✅ 已完成 | ✅ 已完成 |
|
||||||
| Phase 9: 多知识库管理 | ⏳ 待实现 | ⏳ 待实现 |
|
| Phase 9: 多知识库管理 | ✅ 已完成 | ✅ 已完成 |
|
||||||
| Phase 10: 意图规则管理 | ✅ 已完成 | ⏳ 待实现 |
|
| Phase 10: 意图规则管理 | ✅ 已完成 | ✅ 已完成 |
|
||||||
| Phase 11: 话术流程管理 | ⏳ 待实现 | ⏳ 待实现 |
|
| Phase 11: 话术流程管理 | ✅ 已完成 | ✅ 已完成 |
|
||||||
| Phase 12: 输出护栏管理 | ⏳ 待实现 | ⏳ 待实现 |
|
| Phase 12: 输出护栏管理 | ✅ 已完成 | ✅ 已完成 |
|
||||||
|
|
||||||
### sub_tasks
|
### sub_tasks
|
||||||
|
|
||||||
|
|
@ -88,9 +88,9 @@ version: "0.6.0"
|
||||||
|
|
||||||
### next_action
|
### next_action
|
||||||
|
|
||||||
**immediate**: 实现 Phase 8 Prompt 模板管理前端页面(后端 API 已完成)
|
**immediate**: ✅ Phase 8-12 前端页面已全部实现完成,待集成测试
|
||||||
|
|
||||||
**commit message**: `feat(ASA-P8): 实现 Prompt 模板管理页面 [AC-ASA-23~AC-ASA-28]`
|
**commit message**: `feat(ASA): 实现 Phase 8-12 前端管理页面 [AC-ASA-23~AC-ASA-44]`
|
||||||
|
|
||||||
### backend_implementation_summary
|
### backend_implementation_summary
|
||||||
|
|
||||||
|
|
@ -258,6 +258,43 @@ export const useTenantStore = defineStore('tenant', {
|
||||||
- 前端 Phase 10 意图规则管理页面待实现
|
- 前端 Phase 10 意图规则管理页面待实现
|
||||||
- 后端 T12.6(Orchestrator 集成)和 T12.7(单元测试)留待集成阶段
|
- 后端 T12.6(Orchestrator 集成)和 T12.7(单元测试)留待集成阶段
|
||||||
|
|
||||||
|
- session: "Session #7 (2026-02-27) - Phase 8-12 前端页面实现"
|
||||||
|
completed:
|
||||||
|
- 实现 Phase 8 Prompt 模板管理前端页面 [AC-ASA-23~AC-ASA-28]
|
||||||
|
- 实现 Phase 9 多知识库管理前端页面 [AC-ASA-29~AC-ASA-33]
|
||||||
|
- 实现 Phase 10 意图规则管理前端页面 [AC-ASA-34~AC-ASA-36]
|
||||||
|
- 实现 Phase 11 话术流程管理前端页面 [AC-ASA-37~AC-ASA-39]
|
||||||
|
- 实现 Phase 12 输出护栏管理前端页面 [AC-ASA-40~AC-ASA-44]
|
||||||
|
- 创建 API 服务层和类型定义(prompt-template, knowledge-base, intent-rule, script-flow, guardrail)
|
||||||
|
- 更新路由配置,注册 5 个新页面
|
||||||
|
- 添加 vuedraggable 依赖用于话术流程步骤拖拽排序
|
||||||
|
changes:
|
||||||
|
- ai-service-admin/src/types/prompt-template.ts - 新增
|
||||||
|
- ai-service-admin/src/types/knowledge-base.ts - 新增
|
||||||
|
- ai-service-admin/src/types/intent-rule.ts - 新增
|
||||||
|
- ai-service-admin/src/types/script-flow.ts - 新增
|
||||||
|
- ai-service-admin/src/types/guardrail.ts - 新增
|
||||||
|
- ai-service-admin/src/api/prompt-template.ts - 新增
|
||||||
|
- ai-service-admin/src/api/knowledge-base.ts - 新增
|
||||||
|
- ai-service-admin/src/api/intent-rule.ts - 新增
|
||||||
|
- ai-service-admin/src/api/script-flow.ts - 新增
|
||||||
|
- ai-service-admin/src/api/guardrail.ts - 新增
|
||||||
|
- ai-service-admin/src/views/admin/prompt-template/index.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/prompt-template/components/TemplateDetail.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/knowledge-base/index.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/knowledge-base/components/DocumentList.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/intent-rule/index.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/intent-rule/components/KeywordInput.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/intent-rule/components/PatternInput.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/script-flow/index.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/script-flow/components/FlowPreview.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/guardrail/index.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/guardrail/components/ForbiddenWordsTab.vue - 新增
|
||||||
|
- ai-service-admin/src/views/admin/guardrail/components/BehaviorRulesTab.vue - 新增
|
||||||
|
- ai-service-admin/src/router/index.ts - 更新(添加 5 个新路由)
|
||||||
|
- ai-service-admin/package.json - 更新(添加 vuedraggable 依赖)
|
||||||
|
- docs/progress/ai-service-admin-progress.md - 更新进度
|
||||||
|
|
||||||
## startup_guide
|
## startup_guide
|
||||||
|
|
||||||
1. **Step 1**: 读取本进度文档(了解当前位置与下一步)
|
1. **Step 1**: 读取本进度文档(了解当前位置与下一步)
|
||||||
|
|
@ -277,10 +314,10 @@ export const useTenantStore = defineStore('tenant', {
|
||||||
| Phase 5 | 后端管理接口实现 | 6 | ✅ 完成 |
|
| Phase 5 | 后端管理接口实现 | 6 | ✅ 完成 |
|
||||||
| Phase 6 | 嵌入模型管理 | 8 | ✅ 完成 |
|
| Phase 6 | 嵌入模型管理 | 8 | ✅ 完成 |
|
||||||
| Phase 7 | LLM 配置与 RAG 调试输出 | 10 | ✅ 完成 |
|
| Phase 7 | LLM 配置与 RAG 调试输出 | 10 | ✅ 完成 |
|
||||||
| Phase 8 | Prompt 模板管理 | 6 | ⏳ 待实现 (后端已完成) |
|
| Phase 8 | Prompt 模板管理 | 6 | ✅ 完成 |
|
||||||
| Phase 9 | 多知识库管理 | 6 | ⏳ 待实现 |
|
| Phase 9 | 多知识库管理 | 6 | ✅ 完成 |
|
||||||
| Phase 10 | 意图规则管理 | 6 | ⏳ 待实现 (后端已完成) |
|
| Phase 10 | 意图规则管理 | 6 | ✅ 完成 |
|
||||||
| Phase 11 | 话术流程管理 | 5 | ⏳ 待实现 |
|
| Phase 11 | 话术流程管理 | 5 | ✅ 完成 |
|
||||||
| Phase 12 | 输出护栏管理 | 7 | ⏳ 待实现 |
|
| Phase 12 | 输出护栏管理 | 7 | ✅ 完成 |
|
||||||
|
|
||||||
**总计: 71 个任务 | 已完成: 41 个 | 待处理: 30 个 | 进行中: 0 个**
|
**总计: 71 个任务 | 已完成: 71 个 | 待处理: 0 个 | 进行中: 0 个**
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
---
|
---
|
||||||
module: ai-service-admin
|
module: ai-service-admin
|
||||||
title: "AI 中台管理界面(ai-service-admin)任务清单"
|
title: "AI 中台管理界面(ai-service-admin)任务清单"
|
||||||
status: "completed"
|
status: "in-progress"
|
||||||
version: "0.4.0"
|
version: "0.6.0"
|
||||||
owners:
|
owners:
|
||||||
- "frontend"
|
- "frontend"
|
||||||
- "backend"
|
- "backend"
|
||||||
last_updated: "2026-02-25"
|
last_updated: "2026-02-27"
|
||||||
principles:
|
principles:
|
||||||
- atomic
|
- atomic
|
||||||
- page-oriented
|
- page-oriented
|
||||||
|
|
@ -243,3 +243,123 @@ principles:
|
||||||
| P7-01 | 租户 API 服务层 | ✅ 已完成 |
|
| P7-01 | 租户 API 服务层 | ✅ 已完成 |
|
||||||
| P7-02 | 租户选择器组件 | ✅ 已完成 |
|
| P7-02 | 租户选择器组件 | ✅ 已完成 |
|
||||||
| P7-03 | 租户持久化 | ✅ 已完成 |
|
| P7-03 | 租户持久化 | ✅ 已完成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Prompt 模板管理(v0.6.0)
|
||||||
|
|
||||||
|
> 页面导向:Prompt 模板列表页 + 模板编辑器 + 版本管理。
|
||||||
|
|
||||||
|
- [x] (P8-01) API 服务层与类型定义:创建 `src/api/prompt-template.ts` 和 `src/types/prompt-template.ts`
|
||||||
|
- AC: [AC-ASA-23, AC-ASA-24, AC-ASA-25, AC-ASA-26, AC-ASA-27, AC-ASA-28]
|
||||||
|
|
||||||
|
- [x] (P8-02) Prompt 模板列表页:实现模板列表展示(名称、场景标签、发布版本号、更新时间),支持按场景筛选
|
||||||
|
- AC: [AC-ASA-23]
|
||||||
|
|
||||||
|
- [x] (P8-03) 模板创建/编辑表单:实现模板表单(名称、场景选择、系统指令编辑区),系统指令编辑区支持 `{{variable}}` 语法高亮
|
||||||
|
- AC: [AC-ASA-24, AC-ASA-25]
|
||||||
|
|
||||||
|
- [x] (P8-04) 内置变量参考面板:在编辑区侧边展示可用变量列表(persona_name/current_time/channel_type 等),支持点击插入
|
||||||
|
- AC: [AC-ASA-24]
|
||||||
|
|
||||||
|
- [x] (P8-05) 模板详情与版本历史:实现详情页/抽屉,展示当前发布版本内容 + 版本历史时间线
|
||||||
|
- AC: [AC-ASA-28]
|
||||||
|
|
||||||
|
- [x] (P8-06) 发布与回滚操作:实现发布确认对话框 + 版本历史中的回滚按钮
|
||||||
|
- AC: [AC-ASA-26, AC-ASA-27]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: 多知识库管理(v0.6.0)
|
||||||
|
|
||||||
|
> 页面导向:知识库列表页(替代原有单一文档列表)+ 知识库内文档管理。
|
||||||
|
|
||||||
|
- [x] (P9-01) API 服务层与类型定义:创建 `src/api/knowledge-base.ts` 和 `src/types/knowledge-base.ts`
|
||||||
|
- AC: [AC-ASA-29, AC-ASA-30, AC-ASA-32, AC-ASA-33]
|
||||||
|
|
||||||
|
- [x] (P9-02) 知识库列表页:实现知识库卡片/列表视图(名称、类型标签色块、文档数、优先级、启用状态开关)
|
||||||
|
- AC: [AC-ASA-29]
|
||||||
|
|
||||||
|
- [x] (P9-03) 知识库创建/编辑表单:实现创建表单(名称、类型选择下拉、描述、优先级数字输入)
|
||||||
|
- AC: [AC-ASA-30, AC-ASA-32]
|
||||||
|
|
||||||
|
- [x] (P9-04) 知识库删除确认:实现二次确认对话框,提示将删除所有关联文档和索引
|
||||||
|
- AC: [AC-ASA-33]
|
||||||
|
|
||||||
|
- [x] (P9-05) 知识库内文档管理:点击知识库进入文档列表视图,复用现有文档列表组件,上传时自动关联 kbId
|
||||||
|
- AC: [AC-ASA-31]
|
||||||
|
|
||||||
|
- [x] (P9-06) 改造现有知识库页面:将原有 `/kb` 页面从单一文档列表改为知识库列表 → 文档列表的两级结构
|
||||||
|
- AC: [AC-ASA-29, AC-ASA-31]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: 意图规则管理(v0.6.0)
|
||||||
|
|
||||||
|
> 页面导向:意图规则列表页 + 规则创建/编辑表单。
|
||||||
|
|
||||||
|
- [x] (P10-01) API 服务层与类型定义:创建 `src/api/intent-rule.ts` 和 `src/types/intent-rule.ts`
|
||||||
|
- AC: [AC-ASA-34, AC-ASA-35, AC-ASA-36]
|
||||||
|
|
||||||
|
- [x] (P10-02) 意图规则列表页:实现规则列表(意图名称、关键词摘要、响应类型标签、优先级、命中次数、启用开关),支持按响应类型筛选
|
||||||
|
- AC: [AC-ASA-34]
|
||||||
|
|
||||||
|
- [x] (P10-03) 规则创建/编辑表单:实现动态表单,响应类型切换时动态展示对应配置区(fixed→文本编辑、rag→知识库多选、flow→流程选择、transfer→话术编辑)
|
||||||
|
- AC: [AC-ASA-35]
|
||||||
|
|
||||||
|
- [x] (P10-04) 关键词标签输入组件:实现多关键词标签输入(支持回车添加、点击删除)
|
||||||
|
- AC: [AC-ASA-35]
|
||||||
|
|
||||||
|
- [x] (P10-05) 正则表达式输入组件:实现多正则输入(支持添加/删除,带基础语法校验提示)
|
||||||
|
- AC: [AC-ASA-35]
|
||||||
|
|
||||||
|
- [x] (P10-06) 规则删除确认与启用/禁用切换
|
||||||
|
- AC: [AC-ASA-36]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: 话术流程管理(v0.6.0)
|
||||||
|
|
||||||
|
> 页面导向:话术流程列表页 + 流程步骤编辑器。
|
||||||
|
|
||||||
|
- [x] (P11-01) API 服务层与类型定义:创建 `src/api/script-flow.ts` 和 `src/types/script-flow.ts`
|
||||||
|
- AC: [AC-ASA-37, AC-ASA-38, AC-ASA-39]
|
||||||
|
|
||||||
|
- [x] (P11-02) 话术流程列表页:实现流程列表(名称、步骤数、启用状态、关联规则数)
|
||||||
|
- AC: [AC-ASA-37]
|
||||||
|
|
||||||
|
- [x] (P11-03) 流程步骤编辑器:实现步骤列表编辑(添加/删除/拖拽排序),每个步骤卡片包含话术内容、等待输入开关、超时配置、下一步条件
|
||||||
|
- AC: [AC-ASA-38]
|
||||||
|
|
||||||
|
- [x] (P11-04) 步骤条件配置组件:实现下一步条件编辑(关键词匹配 + 跳转目标步骤选择)
|
||||||
|
- AC: [AC-ASA-38]
|
||||||
|
|
||||||
|
- [x] (P11-05) 流程预览视图:以步骤时间线/流程图形式展示完整流程,直观呈现步骤间跳转关系
|
||||||
|
- AC: [AC-ASA-39]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 12: 输出护栏管理(v0.6.0)
|
||||||
|
|
||||||
|
> 页面导向:护栏管理页面(禁词标签页 + 行为规则标签页)。
|
||||||
|
|
||||||
|
- [x] (P12-01) API 服务层与类型定义:创建 `src/api/guardrail.ts` 和 `src/types/guardrail.ts`
|
||||||
|
- AC: [AC-ASA-40, AC-ASA-41, AC-ASA-42, AC-ASA-43, AC-ASA-44]
|
||||||
|
|
||||||
|
- [x] (P12-02) 护栏管理页面骨架:创建 `/admin/guardrails` 页面,实现「禁词管理」和「行为规则」双标签页布局
|
||||||
|
- AC: [AC-ASA-40]
|
||||||
|
|
||||||
|
- [x] (P12-03) 禁词列表与筛选:实现禁词表格(词语、类别标签、策略标签、命中次数、启用开关),支持按类别筛选
|
||||||
|
- AC: [AC-ASA-41]
|
||||||
|
|
||||||
|
- [x] (P12-04) 禁词添加/编辑表单:实现动态表单,策略切换时展示对应配置(replace→替换文本、block→兜底话术)
|
||||||
|
- AC: [AC-ASA-42]
|
||||||
|
|
||||||
|
- [x] (P12-05) 禁词批量导入:实现 CSV/文本批量导入功能(每行一个词,可选默认类别和策略)
|
||||||
|
- AC: [AC-ASA-41]
|
||||||
|
|
||||||
|
- [x] (P12-06) 行为规则列表与管理:实现行为规则表格(规则描述、类别、启用开关),支持添加/编辑/删除
|
||||||
|
- AC: [AC-ASA-43, AC-ASA-44]
|
||||||
|
|
||||||
|
- [x] (P12-07) 路由注册与导航菜单:将新增的 5 个页面(Prompt 模板、知识库、意图规则、话术流程、输出护栏)注册到路由和侧边导航菜单
|
||||||
|
- AC: [AC-ASA-23~AC-ASA-44]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue