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:
MerCry 2026-02-27 18:33:25 +08:00
parent d4b0bc3101
commit 932d4d15ab
26 changed files with 4414 additions and 24 deletions

View File

@ -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"
}, },

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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: '输出护栏管理' }
} }
] ]

View File

@ -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' }
]

View File

@ -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' }
}

View File

@ -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' }
}

View File

@ -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: '对话历史' }
]

View File

@ -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: '转人工' }
]

View File

@ -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="请输入规则描述,例如:&#10;- 不承诺具体价格或优惠&#10;- 不评价竞品&#10;- 保持专业、友好的语气"
/>
</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>

View File

@ -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="每行一个禁词,例如:&#10;竞品A&#10;竞品B&#10;敏感词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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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.6Orchestrator 集成)和 T12.7(单元测试)留待集成阶段 - 后端 T12.6Orchestrator 集成)和 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 个**

View File

@ -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]