feat: update admin frontend with scene-slot-bundle, metadata schema, and mid-platform playground pages [AC-ADMIN-FE]

This commit is contained in:
MerCry 2026-03-10 12:09:00 +08:00
parent 9769f7ccf0
commit 759eafb490
20 changed files with 1985 additions and 115 deletions

View File

@ -13,59 +13,66 @@
<span class="logo-text">AI Robot</span>
</div>
<nav class="main-nav">
<router-link to="/dashboard" class="nav-item" :class="{ active: isActive('/dashboard') }">
<el-icon><Odometer /></el-icon>
<span>控制台</span>
</router-link>
<router-link to="/admin/knowledge-bases" class="nav-item" :class="{ active: isActive('/admin/knowledge-bases') }">
<el-icon><FolderOpened /></el-icon>
<span>知识库</span>
</router-link>
<router-link to="/rag-lab" class="nav-item" :class="{ active: isActive('/rag-lab') }">
<el-icon><Cpu /></el-icon>
<span>RAG 实验室</span>
</router-link>
<router-link to="/monitoring" class="nav-item" :class="{ active: isActive('/monitoring') }">
<el-icon><Monitor /></el-icon>
<span>会话监控</span>
</router-link>
<div class="nav-divider"></div>
<router-link to="/admin/embedding" class="nav-item" :class="{ active: isActive('/admin/embedding') }">
<el-icon><Connection /></el-icon>
<span>嵌入模型</span>
</router-link>
<router-link to="/admin/llm" class="nav-item" :class="{ active: isActive('/admin/llm') }">
<el-icon><ChatDotSquare /></el-icon>
<span>LLM 配置</span>
</router-link>
<router-link to="/admin/prompt-templates" class="nav-item" :class="{ active: isActive('/admin/prompt-templates') }">
<el-icon><Document /></el-icon>
<span>Prompt 模板</span>
</router-link>
<router-link to="/admin/intent-rules" class="nav-item" :class="{ active: isActive('/admin/intent-rules') }">
<el-icon><Aim /></el-icon>
<span>意图规则</span>
</router-link>
<router-link to="/admin/script-flows" class="nav-item" :class="{ active: isActive('/admin/script-flows') }">
<el-icon><Share /></el-icon>
<span>话术流程</span>
</router-link>
<router-link to="/admin/guardrails" class="nav-item" :class="{ active: isActive('/admin/guardrails') }">
<el-icon><Warning /></el-icon>
<span>输出护栏</span>
</router-link>
<router-link to="/admin/mid-platform-playground" class="nav-item" :class="{ active: isActive('/admin/mid-platform-playground') }">
<el-icon><ChatLineRound /></el-icon>
<span>中台联调</span>
</router-link>
<router-link to="/admin/metadata-schemas" class="nav-item" :class="{ active: isActive('/admin/metadata-schemas') }">
<el-icon><Setting /></el-icon>
<span>元数据配置</span>
</router-link>
<router-link to="/admin/slot-definitions" class="nav-item" :class="{ active: isActive('/admin/slot-definitions') }">
<el-icon><Grid /></el-icon>
<span>槽位定义</span>
</router-link>
<div class="nav-row">
<router-link to="/dashboard" class="nav-item" :class="{ active: isActive('/dashboard') }">
<el-icon><Odometer /></el-icon>
<span>控制台</span>
</router-link>
<router-link to="/admin/knowledge-bases" class="nav-item" :class="{ active: isActive('/admin/knowledge-bases') }">
<el-icon><FolderOpened /></el-icon>
<span>知识库</span>
</router-link>
<router-link to="/rag-lab" class="nav-item" :class="{ active: isActive('/rag-lab') }">
<el-icon><Cpu /></el-icon>
<span>RAG 实验室</span>
</router-link>
<router-link to="/monitoring" class="nav-item" :class="{ active: isActive('/monitoring') }">
<el-icon><Monitor /></el-icon>
<span>会话监控</span>
</router-link>
</div>
<div class="nav-row">
<router-link to="/admin/embedding" class="nav-item" :class="{ active: isActive('/admin/embedding') }">
<el-icon><Connection /></el-icon>
<span>嵌入模型</span>
</router-link>
<router-link to="/admin/llm" class="nav-item" :class="{ active: isActive('/admin/llm') }">
<el-icon><ChatDotSquare /></el-icon>
<span>LLM 配置</span>
</router-link>
<router-link to="/admin/prompt-templates" class="nav-item" :class="{ active: isActive('/admin/prompt-templates') }">
<el-icon><Document /></el-icon>
<span>Prompt 模板</span>
</router-link>
<router-link to="/admin/intent-rules" class="nav-item" :class="{ active: isActive('/admin/intent-rules') }">
<el-icon><Aim /></el-icon>
<span>意图规则</span>
</router-link>
<router-link to="/admin/script-flows" class="nav-item" :class="{ active: isActive('/admin/script-flows') }">
<el-icon><Share /></el-icon>
<span>话术流程</span>
</router-link>
<router-link to="/admin/guardrails" class="nav-item" :class="{ active: isActive('/admin/guardrails') }">
<el-icon><Warning /></el-icon>
<span>输出护栏</span>
</router-link>
<router-link to="/admin/mid-platform-playground" class="nav-item" :class="{ active: isActive('/admin/mid-platform-playground') }">
<el-icon><ChatLineRound /></el-icon>
<span>中台联调</span>
</router-link>
<router-link to="/admin/metadata-schemas" class="nav-item" :class="{ active: isActive('/admin/metadata-schemas') }">
<el-icon><Setting /></el-icon>
<span>元数据配置</span>
</router-link>
<router-link to="/admin/slot-definitions" class="nav-item" :class="{ active: isActive('/admin/slot-definitions') }">
<el-icon><Grid /></el-icon>
<span>槽位定义</span>
</router-link>
<router-link to="/admin/scene-slot-bundles" class="nav-item" :class="{ active: isActive('/admin/scene-slot-bundles') }">
<el-icon><Collection /></el-icon>
<span>场景槽位包</span>
</router-link>
</div>
</nav>
</div>
<div class="header-right">
@ -98,7 +105,7 @@ import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useTenantStore } from '@/stores/tenant'
import { getTenantList, type Tenant } from '@/api/tenant'
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare, Document, Aim, Share, Warning, Setting, ChatLineRound, Grid } from '@element-plus/icons-vue'
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare, Document, Aim, Share, Warning, Setting, ChatLineRound, Grid, Collection } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const route = useRoute()
@ -168,7 +175,8 @@ onMounted(() => {
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
height: auto;
min-height: 60px;
background-color: var(--bg-secondary, #FFFFFF);
border-bottom: 1px solid var(--border-color, #E2E8F0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
@ -208,6 +216,12 @@ onMounted(() => {
}
.main-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-row {
display: flex;
align-items: center;
gap: 4px;

View File

@ -36,3 +36,19 @@ export function deleteDocument(docId: string) {
method: 'delete'
})
}
export function batchUploadDocuments(data: FormData) {
return request({
url: '/admin/kb/documents/batch-upload',
method: 'post',
data
})
}
export function jsonBatchUpload(kbId: string, data: FormData) {
return request({
url: `/admin/kb/${kbId}/documents/json-batch`,
method: 'post',
data
})
}

View File

@ -11,7 +11,7 @@ import type {
export const metadataSchemaApi = {
list: (status?: 'draft' | 'active' | 'deprecated', fieldRole?: FieldRole) =>
request<MetadataFieldListResponse>({
request<MetadataFieldDefinition[]>({
method: 'GET',
url: '/admin/metadata-schemas',
params: {

View File

@ -35,6 +35,8 @@ export interface ToolCallTrace {
error_code?: string
args_digest?: string
result_digest?: string
arguments?: Record<string, unknown>
result?: unknown
}
export interface DialogueResponse {
@ -219,3 +221,16 @@ export function sendSharedMessage(shareToken: string, userMessage: string): Prom
data: { user_message: userMessage }
})
}
export interface CancelFlowResponse {
success: boolean
message: string
session_id: string
}
export function cancelActiveFlow(sessionId: string): Promise<CancelFlowResponse> {
return request({
url: `/mid/sessions/${sessionId}/cancel-flow`,
method: 'post'
})
}

View File

@ -0,0 +1,48 @@
import request from '@/utils/request'
import type {
SceneSlotBundle,
SceneSlotBundleWithDetails,
SceneSlotBundleCreateRequest,
SceneSlotBundleUpdateRequest,
} from '@/types/scene-slot-bundle'
export const sceneSlotBundleApi = {
list: (status?: string) =>
request<SceneSlotBundle[]>({
method: 'GET',
url: '/admin/scene-slot-bundles',
params: status ? { status } : {},
}),
get: (id: string) =>
request<SceneSlotBundleWithDetails>({
method: 'GET',
url: `/admin/scene-slot-bundles/${id}`,
}),
getBySceneKey: (sceneKey: string) =>
request<SceneSlotBundle>({
method: 'GET',
url: `/admin/scene-slot-bundles/by-scene/${sceneKey}`,
}),
create: (data: SceneSlotBundleCreateRequest) =>
request<SceneSlotBundle>({
method: 'POST',
url: '/admin/scene-slot-bundles',
data,
}),
update: (id: string, data: SceneSlotBundleUpdateRequest) =>
request<SceneSlotBundle>({
method: 'PUT',
url: `/admin/scene-slot-bundles/${id}`,
data,
}),
delete: (id: string) =>
request<void>({
method: 'DELETE',
url: `/admin/scene-slot-bundles/${id}`,
}),
}

View File

@ -99,17 +99,19 @@ const deprecatedFields = computed(() => {
})
const visibleFields = computed(() => {
// Show all fields except deprecated in edit mode, so users can change draft to active
if (props.isNewObject) {
return activeFields.value
}
return allFields.value.filter(f => f.status !== 'draft')
return allFields.value.filter(f => f.status !== 'deprecated')
})
const loadFields = async () => {
loading.value = true
try {
const res = await metadataSchemaApi.getByScope(props.scope, props.showDeprecated)
allFields.value = res.items || []
// Handle both formats: direct array or { items: array }
allFields.value = Array.isArray(res) ? res : (res.items || [])
emit('fields-loaded', allFields.value)
applyDefaults()

View File

@ -17,12 +17,6 @@ const routes: Array<RouteRecordRaw> = [
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '控制台' }
},
{
path: '/kb',
name: 'KBManagement',
component: () => import('@/views/kb/index.vue'),
meta: { title: '知识库管理' }
},
{
path: '/rag-lab',
name: 'RagLab',
@ -71,6 +65,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import('@/views/admin/slot-definition/index.vue'),
meta: { title: '槽位定义管理' }
},
{
path: '/admin/scene-slot-bundles',
name: 'SceneSlotBundle',
component: () => import('@/views/admin/scene-slot-bundle/index.vue'),
meta: { title: '场景槽位包管理' }
},
{
path: '/admin/intent-rules',
name: 'IntentRule',

View File

@ -15,6 +15,7 @@ export interface MetadataFieldDefinition {
scope: MetadataScope[]
is_filterable: boolean
is_rank_feature: boolean
usage_description?: string
field_roles: FieldRole[]
status: MetadataFieldStatus
created_at?: string
@ -31,6 +32,7 @@ export interface MetadataFieldCreateRequest {
scope: MetadataScope[]
is_filterable?: boolean
is_rank_feature?: boolean
usage_description?: string
field_roles?: FieldRole[]
status: MetadataFieldStatus
}
@ -43,6 +45,7 @@ export interface MetadataFieldUpdateRequest {
scope?: MetadataScope[]
is_filterable?: boolean
is_rank_feature?: boolean
usage_description?: string
field_roles?: FieldRole[]
status?: MetadataFieldStatus
}

View File

@ -0,0 +1,80 @@
/**
* Scene Slot Bundle Types.
* [AC-SCENE-SLOT-01] -
*/
export type SceneSlotBundleStatus = 'draft' | 'active' | 'deprecated'
export type AskBackOrder = 'priority' | 'required_first' | 'parallel'
export interface SceneSlotBundle {
id: string
tenant_id: string
scene_key: string
scene_name: string
description: string | null
required_slots: string[]
optional_slots: string[]
slot_priority: string[] | null
completion_threshold: number
ask_back_order: AskBackOrder
status: SceneSlotBundleStatus
version: number
created_at: string | null
updated_at: string | null
}
export interface SlotDetail {
slot_key: string
type: string
required: boolean
ask_back_prompt: string | null
linked_field_id: string | null
}
export interface SceneSlotBundleWithDetails extends SceneSlotBundle {
required_slot_details: SlotDetail[]
optional_slot_details: SlotDetail[]
}
export interface SceneSlotBundleCreateRequest {
scene_key: string
scene_name: string
description?: string
required_slots?: string[]
optional_slots?: string[]
slot_priority?: string[]
completion_threshold?: number
ask_back_order?: AskBackOrder
status?: SceneSlotBundleStatus
}
export interface SceneSlotBundleUpdateRequest {
scene_name?: string
description?: string
required_slots?: string[]
optional_slots?: string[]
slot_priority?: string[]
completion_threshold?: number
ask_back_order?: AskBackOrder
status?: SceneSlotBundleStatus
}
export const SCENE_SLOT_BUNDLE_STATUS_OPTIONS = [
{ value: 'draft', label: '草稿', description: '配置中,未启用' },
{ value: 'active', label: '已启用', description: '运行时可用' },
{ value: 'deprecated', label: '已废弃', description: '不再使用' },
]
export const ASK_BACK_ORDER_OPTIONS = [
{ value: 'priority', label: '按优先级', description: '按 slot_priority 定义的顺序追问' },
{ value: 'required_first', label: '必填优先', description: '优先追问必填槽位' },
{ value: 'parallel', label: '并行追问', description: '一次追问多个缺失槽位' },
]
export function getStatusLabel(status: SceneSlotBundleStatus): string {
return SCENE_SLOT_BUNDLE_STATUS_OPTIONS.find(o => o.value === status)?.label || status
}
export function getAskBackOrderLabel(order: AskBackOrder): string {
return ASK_BACK_ORDER_OPTIONS.find(o => o.value === order)?.label || order
}

View File

@ -39,6 +39,10 @@ export interface FlowStep {
intent_description?: string
script_constraints?: string[]
expected_variables?: string[]
allowed_kb_ids?: string[]
preferred_kb_ids?: string[]
kb_query_hint?: string
max_kb_calls_per_step?: number
}
export interface NextCondition {

View File

@ -2,13 +2,31 @@ export type SlotType = 'string' | 'number' | 'boolean' | 'enum' | 'array_enum'
export type ExtractStrategy = 'rule' | 'llm' | 'user_input'
export type SlotSource = 'user_confirmed' | 'rule_extracted' | 'llm_inferred' | 'default'
/**
* [AC-MRS-07-UPGRADE]
*/
export type ExtractFailureType =
| 'EXTRACT_EMPTY'
| 'EXTRACT_PARSE_FAIL'
| 'EXTRACT_VALIDATION_FAIL'
| 'EXTRACT_RUNTIME_ERROR'
export interface SlotDefinition {
id: string
tenant_id: string
slot_key: string
display_name?: string
description?: string
type: SlotType
required: boolean
/**
* [AC-MRS-07-UPGRADE]
*/
extract_strategy?: ExtractStrategy
/**
* [AC-MRS-07-UPGRADE]
*/
extract_strategies?: ExtractStrategy[]
validation_rule?: string
ask_back_prompt?: string
default_value?: string | number | boolean | string[]
@ -29,8 +47,17 @@ export interface LinkedField {
export interface SlotDefinitionCreateRequest {
tenant_id?: string
slot_key: string
display_name?: string
description?: string
type: SlotType
required: boolean
/**
* [AC-MRS-07-UPGRADE]
*/
extract_strategies?: ExtractStrategy[]
/**
* [AC-MRS-07-UPGRADE]
*/
extract_strategy?: ExtractStrategy
validation_rule?: string
ask_back_prompt?: string
@ -39,8 +66,17 @@ export interface SlotDefinitionCreateRequest {
}
export interface SlotDefinitionUpdateRequest {
display_name?: string
description?: string
type?: SlotType
required?: boolean
/**
* [AC-MRS-07-UPGRADE]
*/
extract_strategies?: ExtractStrategy[]
/**
* [AC-MRS-07-UPGRADE]
*/
extract_strategy?: ExtractStrategy
validation_rule?: string
ask_back_prompt?: string
@ -56,6 +92,31 @@ export interface RuntimeSlotValue {
updated_at?: string
}
/**
* [AC-MRS-07-UPGRADE]
*/
export interface StrategyStepResult {
strategy: ExtractStrategy
success: boolean
value?: string | number | boolean | string[]
failure_type?: ExtractFailureType
failure_reason?: string
execution_time_ms: number
}
/**
* [AC-MRS-07-UPGRADE]
*/
export interface StrategyChainResult {
slot_key: string
success: boolean
final_value?: string | number | boolean | string[]
final_strategy?: ExtractStrategy
steps: StrategyStepResult[]
total_execution_time_ms: number
ask_back_prompt?: string
}
export const SLOT_TYPE_OPTIONS = [
{ value: 'string', label: '文本' },
{ value: 'number', label: '数字' },
@ -69,3 +130,42 @@ export const EXTRACT_STRATEGY_OPTIONS = [
{ value: 'llm', label: 'LLM 推断', description: '通过大语言模型推断槽位值' },
{ value: 'user_input', label: '用户输入', description: '通过追问提示语让用户主动输入' }
]
/**
* [AC-MRS-07-UPGRADE]
*/
export function validateExtractStrategies(strategies: ExtractStrategy[]): { valid: boolean; error?: string } {
if (!strategies || strategies.length === 0) {
return { valid: true } // 空数组视为有效
}
const validStrategies = new Set(['rule', 'llm', 'user_input'])
// 检查重复
const uniqueStrategies = new Set(strategies)
if (uniqueStrategies.size !== strategies.length) {
return { valid: false, error: '提取策略链中不允许重复的策略' }
}
// 检查有效性
const invalid = strategies.filter(s => !validStrategies.has(s))
if (invalid.length > 0) {
return { valid: false, error: `无效的提取策略: ${invalid.join(', ')}` }
}
return { valid: true }
}
/**
* [AC-MRS-07-UPGRADE]
* 使 extract_strategies extract_strategy
*/
export function getEffectiveStrategies(slot: SlotDefinition): ExtractStrategy[] {
if (slot.extract_strategies && slot.extract_strategies.length > 0) {
return slot.extract_strategies
}
if (slot.extract_strategy) {
return [slot.extract_strategy]
}
return []
}

View File

@ -4,7 +4,7 @@ import { useTenantStore } from '@/stores/tenant'
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API || '/api',
timeout: 60000
timeout: 180000
})
service.interceptors.request.use(

View File

@ -5,6 +5,14 @@
<el-icon><Upload /></el-icon>
上传文档
</el-button>
<el-button type="success" @click="handleBatchUploadClick">
<el-icon><Upload /></el-icon>
批量上传
</el-button>
<el-button type="warning" @click="handleJsonUploadClick">
<el-icon><Upload /></el-icon>
JSON上传
</el-button>
<input
ref="fileInputRef"
type="file"
@ -12,6 +20,20 @@
style="display: none"
@change="handleFileSelect"
/>
<input
ref="batchFileInputRef"
type="file"
accept=".zip"
style="display: none"
@change="handleBatchFileSelect"
/>
<input
ref="jsonFileInputRef"
type="file"
accept=".json,.jsonl,.txt"
style="display: none"
@change="handleJsonFileSelect"
/>
</div>
<el-table :data="documents" v-loading="loading" stripe>
@ -165,6 +187,8 @@ const pagination = ref({
})
const fileInputRef = ref<HTMLInputElement>()
const batchFileInputRef = ref<HTMLInputElement>()
const jsonFileInputRef = ref<HTMLInputElement>()
const uploadDialogVisible = ref(false)
const editDialogVisible = ref(false)
const uploading = ref(false)
@ -261,6 +285,14 @@ const handleUploadClick = () => {
fileInputRef.value?.click()
}
const handleBatchUploadClick = () => {
batchFileInputRef.value?.click()
}
const handleJsonUploadClick = () => {
jsonFileInputRef.value?.click()
}
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
@ -289,6 +321,140 @@ const handleFileSelect = (event: Event) => {
}
}
const handleBatchFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) {
ElMessage.error('请上传 .zip 格式的压缩包')
if (batchFileInputRef.value) {
batchFileInputRef.value.value = ''
}
return
}
const maxSize = 100 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('压缩包大小不能超过 100MB')
if (batchFileInputRef.value) {
batchFileInputRef.value.value = ''
}
return
}
try {
loading.value = true
const formData = new FormData()
formData.append('file', file)
formData.append('kb_id', props.kbId)
const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api'
const apiKey = import.meta.env.VITE_APP_API_KEY || ''
const response = await fetch(`${baseUrl}/admin/kb/documents/batch-upload`, {
method: 'POST',
headers: {
'X-Tenant-Id': tenantStore.currentTenantId,
'X-API-Key': apiKey
},
body: formData
})
const result = await response.json()
if (result.success) {
const { total, succeeded, failed, results } = result
ElMessage.success(`批量上传完成!成功: ${succeeded}, 失败: ${failed}, 总计: ${total}`)
// ID
const jobIds: string[] = []
for (const item of results) {
if (item.status === 'created' && item.jobId) {
jobIds.push(item.jobId)
}
}
//
if (jobIds.length > 0) {
pollJobStatusSequential(jobIds)
}
emit('upload-success')
loadDocuments()
} else {
ElMessage.error(result.message || '批量上传失败')
}
} catch (error) {
ElMessage.error('批量上传失败,请重试')
console.error('Batch upload error:', error)
} finally {
loading.value = false
if (batchFileInputRef.value) {
batchFileInputRef.value.value = ''
}
}
}
const handleJsonFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const fileName = file.name.toLowerCase()
if (!fileName.endsWith('.json') && !fileName.endsWith('.jsonl') && !fileName.endsWith('.txt')) {
ElMessage.error('请上传 .json、.jsonl 或 .txt 格式的文件')
if (jsonFileInputRef.value) {
jsonFileInputRef.value.value = ''
}
return
}
try {
loading.value = true
ElMessage.info('正在上传 JSON 文件...')
const formData = new FormData()
formData.append('file', file)
const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api'
const apiKey = import.meta.env.VITE_APP_API_KEY || ''
const response = await fetch(`${baseUrl}/admin/kb/${props.kbId}/documents/json-batch?tenant_id=${tenantStore.currentTenantId}`, {
method: 'POST',
headers: {
'X-Tenant-Id': tenantStore.currentTenantId,
'X-API-Key': apiKey
},
body: formData
})
const result = await response.json()
if (result.success) {
ElMessage.success(`JSON 批量上传成功!成功: ${result.succeeded}, 失败: ${result.failed}`)
if (result.valid_metadata_fields) {
console.log('有效的元数据字段:', result.valid_metadata_fields)
}
if (result.failed > 0) {
const failedItems = result.results.filter((r: any) => !r.success)
console.warn('失败的项目:', failedItems)
}
emit('upload-success')
loadDocuments()
} else {
ElMessage.error(result.message || 'JSON 批量上传失败')
}
} catch (error) {
ElMessage.error('JSON 批量上传失败,请重试')
console.error('JSON batch upload error:', error)
} finally {
loading.value = false
if (jsonFileInputRef.value) {
jsonFileInputRef.value.value = ''
}
}
}
const handleUpload = async () => {
if (!selectedFile.value) {
ElMessage.warning('请选择文件')
@ -310,11 +476,13 @@ const handleUpload = async () => {
formData.append('kb_id', props.kbId)
formData.append('metadata', JSON.stringify(uploadForm.value.metadata))
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api'
const apiKey = import.meta.env.VITE_APP_API_KEY || ''
const response = await fetch(`${baseUrl}/admin/kb/documents`, {
method: 'POST',
headers: {
'X-Tenant-Id': tenantStore.currentTenantId
'X-Tenant-Id': tenantStore.currentTenantId,
'X-API-Key': apiKey
},
body: formData
})
@ -356,12 +524,14 @@ const handleSaveMetadata = async () => {
saving.value = true
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api'
const apiKey = import.meta.env.VITE_APP_API_KEY || ''
const response = await fetch(`${baseUrl}/admin/kb/documents/${currentEditDoc.value.docId}/metadata`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Id': tenantStore.currentTenantId
'X-Tenant-Id': tenantStore.currentTenantId,
'X-API-Key': apiKey
},
body: JSON.stringify({ metadata: editForm.value.metadata })
})
@ -398,14 +568,64 @@ const pollJobStatus = async (jobId: string) => {
ElMessage.error(`文档处理失败: ${job.errorMsg || '未知错误'}`)
loadDocuments()
} else {
setTimeout(poll, 3000)
setTimeout(poll, 10000) // 10
}
} catch (error: any) {
if (error.status === 429) {
console.warn('请求过于频繁,稍后重试')
setTimeout(poll, 15000) // 15
} else {
console.error('轮询任务状态失败', error)
}
} catch (error) {
console.error('轮询任务状态失败', error)
}
}
setTimeout(poll, 2000)
setTimeout(poll, 5000) // 5
}
//
const pollJobStatusSequential = async (jobIds: string[]) => {
const pendingJobs = new Set(jobIds)
const maxPolls = 60
let pollCount = 0
const pollAll = async () => {
if (pollCount >= maxPolls || pendingJobs.size === 0) return
pollCount++
const completedJobs: string[] = []
for (const jobId of pendingJobs) {
try {
const job: IndexJob = await getIndexJob(jobId)
if (job.status === 'completed') {
completedJobs.push(jobId)
} else if (job.status === 'failed') {
completedJobs.push(jobId)
console.error(`文档处理失败: ${job.errorMsg || '未知错误'}`)
}
} catch (error: any) {
if (error.status === 429) {
console.warn('请求过于频繁,稍后重试')
break //
} else {
console.error('轮询任务状态失败', error)
}
}
}
//
completedJobs.forEach(jobId => pendingJobs.delete(jobId))
if (pendingJobs.size > 0) {
setTimeout(pollAll, 10000) // 10
} else {
ElMessage.success('所有文档处理完成')
loadDocuments()
}
}
setTimeout(pollAll, 5000) // 5
}
const handleDelete = async (row: DocumentWithMetadata) => {
@ -438,6 +658,8 @@ onMounted(() => {
.list-header {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination-wrapper {

View File

@ -211,6 +211,14 @@
</el-form-item>
</el-col>
</el-row>
<el-form-item label="用途说明">
<el-input
v-model="formData.usage_description"
type="textarea"
:rows="2"
placeholder="描述该字段的业务用途和使用场景"
/>
</el-form-item>
<el-form-item label="默认值">
<el-input v-model="formData.default_value" placeholder="可选默认值" />
</el-form-item>
@ -308,6 +316,7 @@ const formData = reactive({
scope: [] as MetadataScope[],
is_filterable: true,
is_rank_feature: false,
usage_description: '',
field_roles: [] as FieldRole[],
status: 'draft' as MetadataFieldStatus
})
@ -354,8 +363,11 @@ const getRoleLabel = (role: FieldRole) => {
const fetchFields = async () => {
loading.value = true
try {
const res = await metadataSchemaApi.list(filterStatus.value || undefined, filterRole.value || undefined)
fields.value = res.items || []
const res: any = await metadataSchemaApi.list(filterStatus.value || undefined, filterRole.value || undefined)
// [] / {items: []} / {schemas: []} / {data: []}
fields.value = Array.isArray(res)
? res
: (res?.items || res?.schemas || res?.data || [])
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取元数据字段失败')
} finally {
@ -376,6 +388,7 @@ const handleCreate = () => {
scope: [],
is_filterable: true,
is_rank_feature: false,
usage_description: '',
field_roles: [],
status: 'draft'
})
@ -395,6 +408,7 @@ const handleEdit = (field: MetadataFieldDefinition) => {
scope: [...field.scope],
is_filterable: field.is_filterable,
is_rank_feature: field.is_rank_feature,
usage_description: field.usage_description || '',
field_roles: field.field_roles || [],
status: field.status
})
@ -472,6 +486,7 @@ const handleSubmit = async () => {
scope: formData.scope,
is_filterable: formData.is_filterable,
is_rank_feature: formData.is_rank_feature,
usage_description: formData.usage_description || undefined,
field_roles: formData.field_roles,
status: formData.status
}

View File

@ -30,6 +30,12 @@
<el-form-item>
<el-button :loading="switchingMode" @click="handleSwitchMode">应用模式</el-button>
</el-form-item>
<el-form-item>
<el-button type="warning" :loading="cancellingFlow" @click="handleCancelFlow">取消流程</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleNewSession">新建会话</el-button>
</el-form-item>
</el-form>
</el-card>
@ -98,12 +104,34 @@
<div class="json-panel">
<div class="json-title">tool_calls</div>
<pre>{{ JSON.stringify(lastTrace.tool_calls || [], null, 2) }}</pre>
<div class="json-content">
<el-button
class="copy-btn"
size="small"
text
type="primary"
@click="copyJson(lastTrace.tool_calls || [], 'tool_calls')"
>
复制
</el-button>
<pre>{{ JSON.stringify(lastTrace.tool_calls || [], null, 2) }}</pre>
</div>
</div>
<div class="json-panel">
<div class="json-title">metrics_snapshot</div>
<pre>{{ JSON.stringify(lastTrace.metrics_snapshot || {}, null, 2) }}</pre>
<div class="json-content">
<el-button
class="copy-btn"
size="small"
text
type="primary"
@click="copyJson(lastTrace.metrics_snapshot || {}, 'metrics_snapshot')"
>
复制
</el-button>
<pre>{{ JSON.stringify(lastTrace.metrics_snapshot || {}, null, 2) }}</pre>
</div>
</div>
</div>
<el-empty v-else description="暂无 Trace" :image-size="90" />
@ -180,6 +208,7 @@ import {
createPublicShareToken,
listShares,
deleteShare,
cancelActiveFlow,
type DialogueMessage,
type DialogueResponse,
type SessionMode,
@ -205,6 +234,7 @@ const rollbackToLegacy = ref(false)
const sending = ref(false)
const switchingMode = ref(false)
const reporting = ref(false)
const cancellingFlow = ref(false)
const userInput = ref('')
const conversation = ref<ChatItem[]>([])
@ -221,11 +251,11 @@ const shareForm = ref({
const now = () => new Date().toLocaleTimeString()
const tagType = (role: ChatRole) => {
const tagType = (role: ChatRole): 'primary' | 'success' | 'info' | 'warning' | 'danger' => {
if (role === 'user') return 'info'
if (role === 'assistant') return 'success'
if (role === 'human') return 'warning'
return ''
return 'primary'
}
const toHistory = (): DialogueMessage[] => {
@ -289,6 +319,32 @@ const handleSwitchMode = async () => {
}
}
const handleCancelFlow = async () => {
cancellingFlow.value = true
try {
const result = await cancelActiveFlow(sessionId.value)
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.warning(result.message)
}
} catch {
ElMessage.error('取消流程失败')
} finally {
cancellingFlow.value = false
}
}
const handleNewSession = () => {
const newSessionId = `sess_${Date.now()}`
const newUserId = `user_${Date.now()}`
sessionId.value = newSessionId
userId.value = newUserId
conversation.value = []
lastTrace.value = null
ElMessage.success(`新会话已创建: ${newSessionId}`)
}
const handleReportMessages = async () => {
if (!conversation.value.length) {
ElMessage.warning('暂无可上报消息')
@ -370,6 +426,12 @@ const copyShareUrl = (url: string) => {
ElMessage.success('链接已复制到剪贴板')
}
const copyJson = (data: unknown, name: string) => {
const jsonStr = JSON.stringify(data, null, 2)
navigator.clipboard.writeText(jsonStr)
ElMessage.success(`${name} 已复制到剪贴板`)
}
const formatShareExpires = (expiresAt: string) => {
const expires = new Date(expiresAt)
return expires.toLocaleDateString()
@ -462,6 +524,23 @@ onMounted(() => {
margin-bottom: 4px;
}
.json-content {
position: relative;
}
.json-content .copy-btn {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
background: var(--el-bg-color);
opacity: 0.8;
}
.json-content .copy-btn:hover {
opacity: 1;
}
pre {
background: var(--el-fill-color-lighter);
padding: 8px;

View File

@ -0,0 +1,660 @@
<template>
<div class="scene-slot-bundle-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">场景槽位包管理</h1>
<p class="page-desc">配置场景与槽位的映射关系定义每个场景需要采集的槽位集合[AC-SCENE-SLOT-01]</p>
</div>
<div class="header-actions">
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 140px;">
<el-option
v-for="opt in SCENE_SLOT_BUNDLE_STATUS_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="bundle-card" v-loading="loading">
<el-table :data="bundles" stripe style="width: 100%">
<el-table-column prop="scene_key" label="场景标识" min-width="140">
<template #default="{ row }">
<code class="scene-key">{{ row.scene_key }}</code>
</template>
</el-table-column>
<el-table-column prop="scene_name" label="场景名称" min-width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)" size="small">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="required_slots" label="必填槽位" min-width="160">
<template #default="{ row }">
<div class="slot-tags">
<el-tag
v-for="slotKey in row.required_slots.slice(0, 3)"
:key="slotKey"
size="small"
type="danger"
class="slot-tag"
>
{{ slotKey }}
</el-tag>
<el-tag v-if="row.required_slots.length > 3" size="small" type="info">
+{{ row.required_slots.length - 3 }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="optional_slots" label="可选槽位" min-width="140">
<template #default="{ row }">
<div class="slot-tags" v-if="row.optional_slots.length > 0">
<el-tag
v-for="slotKey in row.optional_slots.slice(0, 3)"
:key="slotKey"
size="small"
type="info"
class="slot-tag"
>
{{ slotKey }}
</el-tag>
<el-tag v-if="row.optional_slots.length > 3" size="small" type="info">
+{{ row.optional_slots.length - 3 }}
</el-tag>
</div>
<span v-else class="no-value">-</span>
</template>
</el-table-column>
<el-table-column prop="completion_threshold" label="完成阈值" width="100">
<template #default="{ row }">
<span>{{ (row.completion_threshold * 100).toFixed(0) }}%</span>
</template>
</el-table-column>
<el-table-column prop="ask_back_order" label="追问策略" width="100">
<template #default="{ row }">
<span>{{ getAskBackOrderLabel(row.ask_back_order) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" 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-empty v-if="!loading && bundles.length === 0" description="暂无场景槽位包" />
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑场景槽位包' : '新建场景槽位包'"
width="800px"
: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="scene_key">
<el-input
v-model="formData.scene_key"
placeholder="如open_consult, refund_apply"
:disabled="isEdit"
/>
<div class="field-hint">唯一标识创建后不可修改</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="场景名称" prop="scene_name">
<el-input v-model="formData.scene_name" placeholder="如:开放咨询、退款申请" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="场景描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="2"
placeholder="描述该场景的业务背景和用途"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" style="width: 100%;">
<el-option
v-for="opt in SCENE_SLOT_BUNDLE_STATUS_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
>
<div class="status-option">
<span>{{ opt.label }}</span>
<span class="status-desc">{{ opt.description }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="完成阈值" prop="completion_threshold">
<el-slider
v-model="formData.completion_threshold"
:min="0"
:max="1"
:step="0.1"
:format-tooltip="(val: number) => `${(val * 100).toFixed(0)}%`"
/>
<div class="field-hint">必填槽位填充比例达到此值视为完成</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="必填槽位" prop="required_slots">
<el-select
v-model="formData.required_slots"
multiple
filterable
style="width: 100%;"
placeholder="选择必填槽位"
>
<el-option
v-for="slot in availableSlots"
:key="slot.id"
:label="`${slot.slot_key} (${slot.ask_back_prompt || '无追问提示'})`"
:value="slot.slot_key"
:disabled="formData.optional_slots.includes(slot.slot_key)"
>
<div class="slot-option">
<span class="slot-key-label">{{ slot.slot_key }}</span>
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">缺失必填槽位时会触发追问不会直接进行 KB 检索</div>
</el-form-item>
<el-form-item label="可选槽位" prop="optional_slots">
<el-select
v-model="formData.optional_slots"
multiple
filterable
style="width: 100%;"
placeholder="选择可选槽位"
>
<el-option
v-for="slot in availableSlots"
:key="slot.id"
:label="slot.slot_key"
:value="slot.slot_key"
:disabled="formData.required_slots.includes(slot.slot_key)"
>
<div class="slot-option">
<span class="slot-key-label">{{ slot.slot_key }}</span>
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">可选槽位用于增强检索效果缺失时不阻塞流程</div>
</el-form-item>
<el-form-item label="槽位优先级" prop="slot_priority">
<div class="priority-editor">
<div class="priority-header">
<span class="priority-hint">拖拽调整槽位采集和追问的优先级顺序</span>
<el-button
v-if="formData.slot_priority && formData.slot_priority.length > 0"
type="danger"
link
size="small"
@click="formData.slot_priority = null"
>
清空
</el-button>
</div>
<div v-if="allSlotsForPriority.length > 0" class="priority-list-wrapper">
<draggable
v-model="allSlotsForPriority"
item-key="slot_key"
handle=".drag-handle"
class="priority-list"
ghost-class="ghost"
>
<template #item="{ element, index }">
<div class="priority-item" :class="{ 'in-priority': isInPriority(element.slot_key) }">
<el-icon class="drag-handle"><Rank /></el-icon>
<span class="priority-order">{{ index + 1 }}</span>
<el-tag
size="small"
:type="formData.required_slots.includes(element.slot_key) ? 'danger' : 'info'"
>
{{ element.slot_key }}
</el-tag>
<el-tag size="small" type="warning" v-if="formData.required_slots.includes(element.slot_key)">
必填
</el-tag>
</div>
</template>
</draggable>
</div>
<div v-else class="priority-empty">
<el-text type="info">请先添加必填或可选槽位</el-text>
</div>
</div>
</el-form-item>
<el-form-item label="追问策略" prop="ask_back_order">
<el-radio-group v-model="formData.ask_back_order">
<el-radio-button
v-for="opt in ASK_BACK_ORDER_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
<div class="field-hint">
{{ ASK_BACK_ORDER_OPTIONS.find(o => o.value === formData.ask_back_order)?.description }}
</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>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Rank } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import draggable from 'vuedraggable'
import { sceneSlotBundleApi } from '@/api/scene-slot-bundle'
import { slotDefinitionApi } from '@/api/slot-definition'
import {
SCENE_SLOT_BUNDLE_STATUS_OPTIONS,
ASK_BACK_ORDER_OPTIONS,
type SceneSlotBundle,
type SceneSlotBundleCreateRequest,
type SceneSlotBundleUpdateRequest,
type SceneSlotBundleStatus,
type AskBackOrder,
getStatusLabel,
getAskBackOrderLabel,
} from '@/types/scene-slot-bundle'
import type { SlotDefinition } from '@/types/slot-definition'
const loading = ref(false)
const bundles = ref<SceneSlotBundle[]>([])
const availableSlots = ref<SlotDefinition[]>([])
const filterStatus = ref<SceneSlotBundleStatus | ''>('')
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()
const formData = reactive({
id: '',
scene_key: '',
scene_name: '',
description: '',
required_slots: [] as string[],
optional_slots: [] as string[],
slot_priority: null as string[] | null,
completion_threshold: 1.0,
ask_back_order: 'priority' as AskBackOrder,
status: 'draft' as SceneSlotBundleStatus,
})
const formRules: FormRules = {
scene_key: [
{ required: true, message: '请输入场景标识', trigger: 'blur' },
{ pattern: /^[a-z][a-z0-9_]*$/, message: '以小写字母开头,仅允许小写字母、数字、下划线', trigger: 'blur' }
],
scene_name: [{ required: true, message: '请输入场景名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const getStatusTagType = (status: SceneSlotBundleStatus): 'success' | 'warning' | 'info' => {
const typeMap: Record<SceneSlotBundleStatus, 'success' | 'warning' | 'info'> = {
'active': 'success',
'draft': 'warning',
'deprecated': 'info',
}
return typeMap[status] || 'info'
}
const allSlotsForPriority = computed({
get: () => {
const allKeys = [...formData.required_slots, ...formData.optional_slots]
const priority = formData.slot_priority || allKeys
const orderedSlots = priority
.filter(key => allKeys.includes(key))
.map(key => availableSlots.value.find(s => s.slot_key === key) || { slot_key: key })
const remainingKeys = allKeys.filter(key => !priority.includes(key))
const remainingSlots = remainingKeys
.map(key => availableSlots.value.find(s => s.slot_key === key) || { slot_key: key })
return [...orderedSlots, ...remainingSlots]
},
set: (value: { slot_key: string }[]) => {
formData.slot_priority = value.map(s => s.slot_key)
}
})
const isInPriority = (slotKey: string): boolean => {
if (!formData.slot_priority) return true
return formData.slot_priority.includes(slotKey)
}
const fetchBundles = async () => {
loading.value = true
try {
const res = await sceneSlotBundleApi.list(filterStatus.value || undefined)
bundles.value = res || []
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取场景槽位包列表失败')
} finally {
loading.value = false
}
}
const fetchAvailableSlots = async () => {
try {
const res = await slotDefinitionApi.list()
availableSlots.value = res || []
} catch (error: any) {
console.error('获取槽位定义失败', error)
}
}
const handleCreate = () => {
isEdit.value = false
Object.assign(formData, {
id: '',
scene_key: '',
scene_name: '',
description: '',
required_slots: [],
optional_slots: [],
slot_priority: null,
completion_threshold: 1.0,
ask_back_order: 'priority',
status: 'draft',
})
dialogVisible.value = true
}
const handleEdit = (bundle: SceneSlotBundle) => {
isEdit.value = true
Object.assign(formData, {
id: bundle.id,
scene_key: bundle.scene_key,
scene_name: bundle.scene_name,
description: bundle.description || '',
required_slots: [...bundle.required_slots],
optional_slots: [...bundle.optional_slots],
slot_priority: bundle.slot_priority ? [...bundle.slot_priority] : null,
completion_threshold: bundle.completion_threshold,
ask_back_order: bundle.ask_back_order,
status: bundle.status,
})
dialogVisible.value = true
}
const handleDelete = async (bundle: SceneSlotBundle) => {
try {
await ElMessageBox.confirm(
`确定要删除场景槽位包「${bundle.scene_name}」吗?`,
'删除确认',
{ type: 'warning' }
)
await sceneSlotBundleApi.delete(bundle.id)
ElMessage.success('删除成功')
fetchBundles()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.message || '删除失败')
}
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const data: SceneSlotBundleCreateRequest | SceneSlotBundleUpdateRequest = {
scene_name: formData.scene_name,
description: formData.description || undefined,
required_slots: formData.required_slots.length > 0 ? formData.required_slots : undefined,
optional_slots: formData.optional_slots.length > 0 ? formData.optional_slots : undefined,
slot_priority: formData.slot_priority || undefined,
completion_threshold: formData.completion_threshold,
ask_back_order: formData.ask_back_order,
status: formData.status,
}
if (isEdit.value) {
await sceneSlotBundleApi.update(formData.id, data as SceneSlotBundleUpdateRequest)
ElMessage.success('更新成功')
} else {
const createData = data as SceneSlotBundleCreateRequest
createData.scene_key = formData.scene_key
await sceneSlotBundleApi.create(createData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchBundles()
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '操作失败')
} finally {
submitting.value = false
}
})
}
watch(filterStatus, () => {
fetchBundles()
})
onMounted(() => {
fetchBundles()
fetchAvailableSlots()
})
</script>
<style scoped lang="scss">
.scene-slot-bundle-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.title-section {
.page-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
color: var(--el-text-color-primary);
}
.page-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.bundle-card {
border-radius: 8px;
}
.scene-key {
padding: 2px 6px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
.slot-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.slot-tag {
margin: 0;
}
.no-value {
color: var(--el-text-color-placeholder);
}
.field-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.status-option {
display: flex;
flex-direction: column;
gap: 2px;
.status-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.slot-option {
display: flex;
align-items: center;
gap: 8px;
.slot-key-label {
font-weight: 500;
font-family: monospace;
}
}
.priority-editor {
border: 1px solid var(--el-border-color);
border-radius: 4px;
padding: 12px;
background-color: var(--el-fill-color-light);
}
.priority-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.priority-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.priority-list-wrapper {
max-height: 300px;
overflow-y: auto;
}
.priority-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.priority-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--el-bg-color);
border-radius: 4px;
border: 1px solid var(--el-border-color-light);
cursor: move;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary-light-5);
}
&.in-priority {
background-color: var(--el-color-primary-light-9);
}
}
.drag-handle {
cursor: move;
color: var(--el-text-color-secondary);
font-size: 16px;
}
.priority-order {
width: 20px;
text-align: center;
font-weight: 600;
color: var(--el-text-color-secondary);
}
.priority-empty {
text-align: center;
padding: 20px;
color: var(--el-text-color-secondary);
}
.ghost {
opacity: 0.5;
background: var(--el-color-primary-light-8);
}
</style>

View File

@ -185,11 +185,23 @@
v-model="element.expected_variables"
multiple
filterable
allow-create
default-first-option
placeholder="输入变量名后回车添加"
placeholder="选择期望提取的槽位"
style="width: 100%"
/>
>
<el-option
v-for="slot in availableSlots"
:key="slot.id"
:label="`${slot.slot_key} (${slot.type})`"
:value="slot.slot_key"
>
<div class="slot-option">
<span class="slot-key">{{ slot.slot_key }}</span>
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
<el-tag size="small" v-if="slot.required" type="danger">必填</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">期望变量必须引用已定义的槽位步骤完成时会检查这些槽位是否已填充</div>
</el-form-item>
</template>
@ -218,14 +230,101 @@
v-model="element.expected_variables"
multiple
filterable
allow-create
default-first-option
placeholder="输入变量名后回车添加"
placeholder="选择期望提取的槽位"
style="width: 100%"
/>
>
<el-option
v-for="slot in availableSlots"
:key="slot.id"
:label="`${slot.slot_key} (${slot.type})`"
:value="slot.slot_key"
>
<div class="slot-option">
<span class="slot-key">{{ slot.slot_key }}</span>
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
<el-tag size="small" v-if="slot.required" type="danger">必填</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">期望变量必须引用已定义的槽位步骤完成时会检查这些槽位是否已填充</div>
</el-form-item>
</template>
<el-divider content-position="left">知识库范围</el-divider>
<div class="kb-binding-section">
<el-form-item label="允许的知识库">
<el-select
v-model="element.allowed_kb_ids"
multiple
filterable
clearable
placeholder="选择允许检索的知识库(为空则使用默认策略)"
style="width: 100%"
>
<el-option
v-for="kb in availableKnowledgeBases"
:key="kb.id"
:label="kb.name"
:value="kb.id"
>
<div class="kb-option">
<span>{{ kb.name }}</span>
<el-tag size="small" :type="getKbTypeTagType(kb.kbType)">{{ getKbTypeLabel(kb.kbType) }}</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">限制该步骤只能从选定的知识库中检索信息为空则使用默认检索策略</div>
</el-form-item>
<el-form-item label="优先知识库">
<el-select
v-model="element.preferred_kb_ids"
multiple
filterable
clearable
placeholder="选择优先检索的知识库"
style="width: 100%"
>
<el-option
v-for="kb in availableKnowledgeBases"
:key="kb.id"
:label="kb.name"
:value="kb.id"
:disabled="element.allowed_kb_ids && element.allowed_kb_ids.length > 0 && !element.allowed_kb_ids.includes(kb.id)"
>
<div class="kb-option">
<span>{{ kb.name }}</span>
<el-tag size="small" :type="getKbTypeTagType(kb.kbType)">{{ getKbTypeLabel(kb.kbType) }}</el-tag>
</div>
</el-option>
</el-select>
<div class="field-hint">检索时优先搜索这些知识库</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="16">
<el-form-item label="检索提示">
<el-input
v-model="element.kb_query_hint"
placeholder="可选:描述本步骤的检索意图,帮助提高检索准确性"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="最大检索次数">
<el-input-number
v-model="element.max_kb_calls_per_step"
:min="1"
:max="5"
placeholder="默认1"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="等待输入">
@ -362,17 +461,24 @@ import {
createScriptFlow,
updateScriptFlow,
deleteScriptFlow,
getScriptFlow
getScriptFlow,
} from '@/api/script-flow'
import { slotDefinitionApi } from '@/api/slot-definition'
import { listKnowledgeBases } from '@/api/knowledge-base'
import { MetadataForm } from '@/components/metadata'
import { TIMEOUT_ACTION_OPTIONS, SCRIPT_MODE_OPTIONS } from '@/types/script-flow'
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep, ScriptMode } from '@/types/script-flow'
import type { SlotDefinition } from '@/types/slot-definition'
import type { KnowledgeBase } from '@/types/knowledge-base'
import { KB_TYPE_MAP } from '@/types/knowledge-base'
import FlowPreview from './components/FlowPreview.vue'
import SimulateDialog from './components/SimulateDialog.vue'
import ConstraintManager from './components/ConstraintManager.vue'
const loading = ref(false)
const flows = ref<ScriptFlow[]>([])
const availableSlots = ref<SlotDefinition[]>([])
const availableKnowledgeBases = ref<KnowledgeBase[]>([])
const dialogVisible = ref(false)
const previewDrawer = ref(false)
const simulateDialogVisible = ref(false)
@ -425,16 +531,55 @@ const loadFlows = async () => {
}
}
const loadAvailableSlots = async () => {
try {
const res = await slotDefinitionApi.list()
availableSlots.value = res || []
} catch (error) {
console.error('加载槽位定义失败', error)
availableSlots.value = []
}
}
const loadAvailableKnowledgeBases = async () => {
try {
const res = await listKnowledgeBases({ is_enabled: true })
availableKnowledgeBases.value = res.data || []
} catch (error) {
console.error('加载知识库列表失败', error)
availableKnowledgeBases.value = []
}
}
const getKbTypeLabel = (kbType: string): string => {
return KB_TYPE_MAP[kbType]?.label || kbType
}
const getKbTypeTagType = (kbType: string): string => {
const typeMap: Record<string, string> = {
product: 'primary',
faq: 'success',
script: 'warning',
policy: 'danger',
general: 'info'
}
return typeMap[kbType] || 'info'
}
const handleCreate = () => {
isEdit.value = false
currentEditId.value = ''
formData.value = defaultFormData()
loadAvailableSlots()
loadAvailableKnowledgeBases()
dialogVisible.value = true
}
const handleEdit = async (row: ScriptFlow) => {
isEdit.value = true
currentEditId.value = row.id
await loadAvailableSlots()
await loadAvailableKnowledgeBases()
try {
const detail = await getScriptFlow(row.id)
formData.value = {
@ -444,7 +589,11 @@ const handleEdit = async (row: ScriptFlow) => {
...step,
script_mode: step.script_mode || 'fixed',
script_constraints: step.script_constraints || [],
expected_variables: step.expected_variables || []
expected_variables: step.expected_variables || [],
allowed_kb_ids: step.allowed_kb_ids || [],
preferred_kb_ids: step.preferred_kb_ids || [],
kb_query_hint: step.kb_query_hint || '',
max_kb_calls_per_step: step.max_kb_calls_per_step || null
})),
is_enabled: detail.is_enabled,
metadata: detail.metadata || {}
@ -739,4 +888,35 @@ onMounted(() => {
border-radius: 4px;
margin-bottom: 8px;
}
.slot-option {
display: flex;
align-items: center;
gap: 8px;
}
.slot-key {
font-weight: 500;
font-family: monospace;
}
.field-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.kb-binding-section {
padding: 12px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
margin-bottom: 12px;
}
.kb-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
</style>

View File

@ -23,7 +23,16 @@
<el-table :data="slots" stripe style="width: 100%">
<el-table-column prop="slot_key" label="槽位标识" min-width="140">
<template #default="{ row }">
<code class="slot-key">{{ row.slot_key }}</code>
<div class="slot-key-cell">
<code class="slot-key">{{ row.slot_key }}</code>
<span v-if="row.display_name" class="display-name">{{ row.display_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">
<span v-if="row.description" class="slot-description">{{ row.description }}</span>
<span v-else class="no-value">-</span>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="100">
@ -38,11 +47,20 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="extract_strategy" label="提取策略" width="120">
<!-- [AC-MRS-07-UPGRADE] 策略链展示 -->
<el-table-column prop="extract_strategies" label="提取策略链" min-width="180">
<template #default="{ row }">
<el-tag v-if="row.extract_strategy" size="small">
{{ getExtractStrategyLabel(row.extract_strategy) }}
</el-tag>
<div v-if="getEffectiveStrategies(row).length > 0" class="strategy-chain">
<el-tag
v-for="(strategy, idx) in getEffectiveStrategies(row)"
:key="idx"
size="small"
:type="getStrategyTagType(strategy)"
class="strategy-tag"
>
{{ idx + 1 }}. {{ getExtractStrategyLabel(strategy) }}
</el-tag>
</div>
<span v-else class="no-value">-</span>
</template>
</el-table-column>
@ -80,7 +98,7 @@
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑槽位定义' : '新建槽位定义'"
width="650px"
width="700px"
:close-on-click-modal="false"
destroy-on-close
>
@ -96,6 +114,25 @@
<div class="field-hint">仅允许小写字母数字下划线</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="槽位名称" prop="display_name">
<el-input
v-model="formData.display_name"
placeholder="如:当前年级"
/>
<div class="field-hint">给运营/教研看的中文名</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="槽位说明" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="2"
placeholder="解释这个槽位采集什么、用于哪里,如:用于课程分层推荐和知识库过滤"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="槽位类型" prop="type">
<el-select v-model="formData.type" style="width: 100%;">
@ -108,21 +145,74 @@
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="是否必填" prop="required">
<el-switch v-model="formData.required" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="提取策略" prop="extract_strategy">
<el-select v-model="formData.extract_strategy" style="width: 100%;" clearable placeholder="选择提取策略">
</el-row>
<!-- [AC-MRS-07-UPGRADE] 提取策略链配置 -->
<el-form-item label="提取策略链" prop="extract_strategies">
<div class="strategy-chain-editor">
<div class="strategy-chain-header">
<span class="chain-hint">按优先级排序系统将依次尝试直到成功</span>
<el-button
v-if="formData.extract_strategies.length > 0"
type="danger"
link
size="small"
@click="clearStrategies"
>
清空
</el-button>
</div>
<div v-if="formData.extract_strategies.length > 0" class="strategy-chain-list">
<draggable
v-model="formData.extract_strategies"
:item-key="(item: ExtractStrategy) => item"
handle=".drag-handle"
class="strategy-list"
>
<template #item="{ element, index }">
<div class="strategy-item">
<el-icon class="drag-handle"><Rank /></el-icon>
<span class="strategy-order">{{ index + 1 }}</span>
<el-tag size="small" :type="getStrategyTagType(element)">
{{ getExtractStrategyLabel(element) }}
</el-tag>
<el-button
type="danger"
link
size="small"
class="remove-btn"
@click="removeStrategy(index)"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</template>
</draggable>
</div>
<div v-else class="strategy-empty">
<el-text type="info">暂无策略请从下方添加</el-text>
</div>
<div class="strategy-add-section">
<el-select
v-model="selectedStrategy"
placeholder="选择要添加的策略"
style="width: 200px;"
clearable
>
<el-option
v-for="opt in EXTRACT_STRATEGY_OPTIONS"
v-for="opt in availableStrategies"
:key="opt.value"
:label="opt.label"
:value="opt.value"
:value="opt.value as ExtractStrategy"
:disabled="isStrategySelected(opt.value as ExtractStrategy)"
>
<div class="extract-option">
<span>{{ opt.label }}</span>
@ -130,9 +220,22 @@
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-button
type="primary"
:disabled="!selectedStrategy"
@click="addStrategy"
>
<el-icon><Plus /></el-icon>
添加
</el-button>
</div>
<div v-if="strategyError" class="strategy-error">
<el-text type="danger">{{ strategyError }}</el-text>
</div>
</div>
</el-form-item>
<el-form-item label="关联字段" prop="linked_field_id">
<el-select
v-model="formData.linked_field_id"
@ -186,10 +289,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import { Plus, Edit, Delete, Rank, Close } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import draggable from 'vuedraggable'
import { slotDefinitionApi } from '@/api/slot-definition'
import { metadataSchemaApi } from '@/api/metadata-schema'
import {
@ -199,7 +303,9 @@ import {
type SlotDefinitionCreateRequest,
type SlotDefinitionUpdateRequest,
type SlotType,
type ExtractStrategy
type ExtractStrategy,
validateExtractStrategies,
getEffectiveStrategies
} from '@/types/slot-definition'
import type { MetadataFieldDefinition } from '@/types/metadata'
@ -212,12 +318,19 @@ const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()
// [AC-MRS-07-UPGRADE]
const selectedStrategy = ref<ExtractStrategy | ''>('')
const strategyError = ref('')
const formData = reactive({
id: '',
slot_key: '',
display_name: '',
description: '',
type: 'string' as SlotType,
required: false,
extract_strategy: '' as ExtractStrategy | '',
// [AC-MRS-07-UPGRADE] 使
extract_strategies: [] as ExtractStrategy[],
validation_rule: '',
ask_back_prompt: '',
default_value: '',
@ -233,6 +346,42 @@ const formRules: FormRules = {
required: [{ required: true, message: '请选择是否必填', trigger: 'change' }]
}
// [AC-MRS-07-UPGRADE]
const availableStrategies = computed(() => {
return EXTRACT_STRATEGY_OPTIONS
})
// [AC-MRS-07-UPGRADE]
const isStrategySelected = (strategy: ExtractStrategy) => {
return formData.extract_strategies.includes(strategy)
}
// [AC-MRS-07-UPGRADE]
const addStrategy = () => {
if (!selectedStrategy.value) return
if (isStrategySelected(selectedStrategy.value)) {
strategyError.value = '该策略已存在于链中'
return
}
formData.extract_strategies.push(selectedStrategy.value)
selectedStrategy.value = ''
strategyError.value = ''
}
// [AC-MRS-07-UPGRADE]
const removeStrategy = (index: number) => {
formData.extract_strategies.splice(index, 1)
strategyError.value = ''
}
// [AC-MRS-07-UPGRADE]
const clearStrategies = () => {
formData.extract_strategies = []
strategyError.value = ''
}
const getTypeLabel = (type: SlotType) => {
return SLOT_TYPE_OPTIONS.find(o => o.value === type)?.label || type
}
@ -241,6 +390,16 @@ const getExtractStrategyLabel = (strategy: ExtractStrategy) => {
return EXTRACT_STRATEGY_OPTIONS.find(o => o.value === strategy)?.label || strategy
}
// [AC-MRS-07-UPGRADE]
const getStrategyTagType = (strategy: ExtractStrategy): any => {
const typeMap: Record<ExtractStrategy, any> = {
'rule': 'success',
'llm': 'warning',
'user_input': 'info'
}
return typeMap[strategy] || 'info'
}
const fetchSlots = async () => {
loading.value = true
try {
@ -256,7 +415,8 @@ const fetchSlots = async () => {
const fetchSlotFields = async () => {
try {
const res = await metadataSchemaApi.getByRole('slot', true)
slotFields.value = res.items || []
//
slotFields.value = Array.isArray(res) ? res : (res.items || [])
} catch (error: any) {
console.error('获取槽位角色字段失败', error)
}
@ -267,14 +427,19 @@ const handleCreate = () => {
Object.assign(formData, {
id: '',
slot_key: '',
display_name: '',
description: '',
type: 'string',
required: false,
extract_strategy: '',
// [AC-MRS-07-UPGRADE]
extract_strategies: [],
validation_rule: '',
ask_back_prompt: '',
default_value: '',
linked_field_id: ''
})
selectedStrategy.value = ''
strategyError.value = ''
dialogVisible.value = true
}
@ -283,14 +448,19 @@ const handleEdit = (slot: SlotDefinition) => {
Object.assign(formData, {
id: slot.id,
slot_key: slot.slot_key,
display_name: slot.display_name || '',
description: slot.description || '',
type: slot.type,
required: slot.required,
extract_strategy: slot.extract_strategy || '',
// [AC-MRS-07-UPGRADE] 使
extract_strategies: [...getEffectiveStrategies(slot)],
validation_rule: slot.validation_rule || '',
ask_back_prompt: slot.ask_back_prompt || '',
default_value: slot.default_value ?? '',
linked_field_id: slot.linked_field_id || ''
})
selectedStrategy.value = ''
strategyError.value = ''
dialogVisible.value = true
}
@ -317,13 +487,25 @@ const handleSubmit = async () => {
await formRef.value.validate(async (valid) => {
if (!valid) return
// [AC-MRS-07-UPGRADE]
if (formData.extract_strategies.length > 0) {
const validation = validateExtractStrategies(formData.extract_strategies)
if (!validation.valid) {
strategyError.value = validation.error || '策略链校验失败'
return
}
}
submitting.value = true
try {
const data: SlotDefinitionCreateRequest | SlotDefinitionUpdateRequest = {
slot_key: formData.slot_key,
display_name: formData.display_name || undefined,
description: formData.description || undefined,
type: formData.type,
required: formData.required,
extract_strategy: formData.extract_strategy || undefined,
// [AC-MRS-07-UPGRADE]
extract_strategies: formData.extract_strategies.length > 0 ? formData.extract_strategies : undefined,
validation_rule: formData.validation_rule || undefined,
ask_back_prompt: formData.ask_back_prompt || undefined,
linked_field_id: formData.linked_field_id || undefined
@ -409,6 +591,27 @@ onMounted(() => {
font-size: 12px;
}
.slot-key-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.display-name {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.slot-description {
color: var(--el-text-color-regular);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
max-width: 180px;
}
.no-value {
color: var(--el-text-color-placeholder);
}
@ -439,6 +642,91 @@ onMounted(() => {
color: var(--el-text-color-secondary);
}
// [AC-MRS-07-UPGRADE]
.strategy-chain {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.strategy-tag {
margin-right: 4px;
}
.strategy-chain-editor {
border: 1px solid var(--el-border-color);
border-radius: 4px;
padding: 12px;
background-color: var(--el-fill-color-light);
}
.strategy-chain-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chain-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.strategy-chain-list {
margin-bottom: 16px;
}
.strategy-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.strategy-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--el-bg-color);
border-radius: 4px;
border: 1px solid var(--el-border-color-light);
}
.drag-handle {
cursor: move;
color: var(--el-text-color-secondary);
font-size: 16px;
}
.strategy-order {
width: 20px;
text-align: center;
font-weight: 600;
color: var(--el-text-color-secondary);
}
.remove-btn {
margin-left: auto;
}
.strategy-empty {
text-align: center;
padding: 20px;
color: var(--el-text-color-secondary);
}
.strategy-add-section {
display: flex;
gap: 8px;
align-items: center;
padding-top: 12px;
border-top: 1px dashed var(--el-border-color);
}
.strategy-error {
margin-top: 8px;
}
.extract-option {
display: flex;
flex-direction: column;

View File

@ -410,6 +410,24 @@
<p>配置知识库意图规则等的动态元数据字段定义</p>
</div>
</div>
<div class="help-item" @click="navigateTo('/admin/slot-definitions')">
<div class="help-icon success">
<el-icon><Grid /></el-icon>
</div>
<div class="help-text">
<h4>槽位定义</h4>
<p>定义槽位类型提取策略校验规则和追问提示</p>
</div>
</div>
<div class="help-item" @click="navigateTo('/admin/scene-slot-bundles')">
<div class="help-icon warning">
<el-icon><Collection /></el-icon>
</div>
<div class="help-text">
<h4>场景槽位包</h4>
<p>配置场景与槽位的映射关系定义每个场景需要采集的槽位</p>
</div>
</div>
</div>
</el-card>
</el-col>
@ -420,7 +438,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine, Aim, DocumentCopy, Share, Warning, Setting } from '@element-plus/icons-vue'
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine, Aim, DocumentCopy, Share, Warning, Setting, Grid, Collection } from '@element-plus/icons-vue'
import { getDashboardStats, type DashboardStats } from '@/api/dashboard'
const router = useRouter()

View File

@ -11,6 +11,14 @@
<el-icon><Upload /></el-icon>
上传文档
</el-button>
<el-button type="success" @click="handleBatchUploadClick">
<el-icon><Upload /></el-icon>
批量上传
</el-button>
<el-button type="warning" @click="handleJsonUploadClick">
<el-icon><Upload /></el-icon>
JSON上传
</el-button>
</div>
</div>
</div>
@ -85,6 +93,8 @@
</el-dialog>
<input ref="fileInput" type="file" style="display: none" @change="handleFileChange" />
<input ref="batchFileInput" type="file" accept=".zip" style="display: none" @change="handleBatchFileChange" />
<input ref="jsonFileInput" type="file" accept=".json,.jsonl,.txt" style="display: none" @change="handleJsonFileChange" />
</div>
</template>
@ -92,7 +102,7 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Document, View, Delete } from '@element-plus/icons-vue'
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
import { uploadDocument, listDocuments, getIndexJob, deleteDocument, batchUploadDocuments, jsonBatchUpload } from '@/api/kb'
interface DocumentItem {
docId: string
@ -242,11 +252,21 @@ onUnmounted(() => {
})
const fileInput = ref<HTMLInputElement | null>(null)
const batchFileInput = ref<HTMLInputElement | null>(null)
const jsonFileInput = ref<HTMLInputElement | null>(null)
const handleUploadClick = () => {
fileInput.value?.click()
}
const handleBatchUploadClick = () => {
batchFileInput.value?.click()
}
const handleJsonUploadClick = () => {
jsonFileInput.value?.click()
}
const handleFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
@ -281,6 +301,111 @@ const handleFileChange = async (event: Event) => {
target.value = ''
}
}
const handleBatchFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) {
ElMessage.error('请上传 .zip 格式的压缩包')
target.value = ''
return
}
const formData = new FormData()
formData.append('file', file)
formData.append('kb_id', 'kb_default')
try {
loading.value = true
const res: any = await batchUploadDocuments(formData)
const { total, succeeded, failed, results } = res
if (succeeded > 0) {
ElMessage.success(`批量上传成功!成功: ${succeeded}, 失败: ${failed}, 总计: ${total}`)
for (const result of results) {
if (result.status === 'created') {
const newDoc: DocumentItem = {
docId: result.docId,
name: result.fileName,
status: 'pending',
jobId: result.jobId,
createTime: new Date().toLocaleString('zh-CN')
}
tableData.value.unshift(newDoc)
startPolling(result.jobId)
}
}
} else {
ElMessage.error('批量上传失败,请检查压缩包格式')
}
if (failed > 0) {
const failedItems = results.filter((r: any) => r.status === 'failed')
console.error('Failed uploads:', failedItems)
}
fetchDocuments()
} catch (error: any) {
ElMessage.error(error.message || '批量上传失败')
console.error('Batch upload error:', error)
} finally {
loading.value = false
target.value = ''
}
}
const handleJsonFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const fileName = file.name.toLowerCase()
if (!fileName.endsWith('.json') && !fileName.endsWith('.jsonl') && !fileName.endsWith('.txt')) {
ElMessage.error('请上传 .json、.jsonl 或 .txt 格式的文件')
target.value = ''
return
}
const kbId = '75c465fe-277d-455d-a30b-4b168adcc03b'
const formData = new FormData()
formData.append('file', file)
formData.append('tenant_id', 'szmp@ash@2026')
try {
loading.value = true
ElMessage.info('正在上传 JSON 文件...')
const res: any = await jsonBatchUpload(kbId, formData)
if (res.success) {
ElMessage.success(`JSON 批量上传成功!成功: ${res.succeeded}, 失败: ${res.failed}`)
if (res.valid_metadata_fields) {
console.log('有效的元数据字段:', res.valid_metadata_fields)
}
if (res.failed > 0) {
const failedItems = res.results.filter((r: any) => !r.success)
console.warn('失败的项目:', failedItems)
}
fetchDocuments()
} else {
ElMessage.error('JSON 批量上传失败')
}
} catch (error: any) {
ElMessage.error(error.message || 'JSON 批量上传失败')
console.error('JSON batch upload error:', error)
} finally {
loading.value = false
target.value = ''
}
}
</script>
<style scoped>
@ -324,6 +449,7 @@ const handleFileChange = async (event: Event) => {
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.table-card {