feat: add slot definition management page [AC-MRS-07,08,16]

This commit is contained in:
MerCry 2026-03-05 17:25:38 +08:00
parent eb7bc7722b
commit 127ce5d8a9
5 changed files with 621 additions and 1 deletions

View File

@ -54,10 +54,18 @@
<el-icon><Warning /></el-icon> <el-icon><Warning /></el-icon>
<span>输出护栏</span> <span>输出护栏</span>
</router-link> </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') }"> <router-link to="/admin/metadata-schemas" class="nav-item" :class="{ active: isActive('/admin/metadata-schemas') }">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
<span>元数据配置</span> <span>元数据配置</span>
</router-link> </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>
</nav> </nav>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -90,7 +98,7 @@ import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useTenantStore } from '@/stores/tenant' import { useTenantStore } from '@/stores/tenant'
import { getTenantList, type Tenant } from '@/api/tenant' import { getTenantList, type Tenant } from '@/api/tenant'
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare, Document, Aim, Share, Warning, Setting } from '@element-plus/icons-vue' import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare, Document, Aim, Share, Warning, Setting, ChatLineRound, Grid } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const route = useRoute() const route = useRoute()

View File

@ -0,0 +1,53 @@
import request from '@/utils/request'
import type {
SlotDefinition,
SlotDefinitionCreateRequest,
SlotDefinitionUpdateRequest,
RuntimeSlotValue
} from '@/types/slot-definition'
import type { FieldRole } from '@/types/metadata'
export const slotDefinitionApi = {
list: (required?: boolean) =>
request<SlotDefinition[]>({
method: 'GET',
url: '/admin/slot-definitions',
params: required !== undefined ? { required } : {}
}),
get: (id: string) =>
request<SlotDefinition>({ method: 'GET', url: `/admin/slot-definitions/${id}` }),
create: (data: SlotDefinitionCreateRequest) =>
request<SlotDefinition>({ method: 'POST', url: '/admin/slot-definitions', data }),
update: (id: string, data: SlotDefinitionUpdateRequest) =>
request<SlotDefinition>({ method: 'PUT', url: `/admin/slot-definitions/${id}`, data }),
delete: (id: string) =>
request({ method: 'DELETE', url: `/admin/slot-definitions/${id}` }),
getByRole: (role: FieldRole) =>
request<SlotDefinition[]>({
method: 'GET',
url: '/mid/slots/by-role',
params: { role }
}),
getSlotValue: (slotKey: string, userId?: string, sessionId?: string) =>
request<RuntimeSlotValue>({
method: 'GET',
url: `/mid/slots/${slotKey}`,
params: {
...(userId ? { user_id: userId } : {}),
...(sessionId ? { session_id: sessionId } : {})
}
})
}
export type {
SlotDefinition,
SlotDefinitionCreateRequest,
SlotDefinitionUpdateRequest,
RuntimeSlotValue
}

View File

@ -5,6 +5,12 @@ const routes: Array<RouteRecordRaw> = [
path: '/', path: '/',
redirect: '/dashboard' redirect: '/dashboard'
}, },
{
path: '/share/:token',
name: 'SharedSession',
component: () => import('@/views/share/index.vue'),
meta: { title: '共享对话', public: true }
},
{ {
path: '/dashboard', path: '/dashboard',
name: 'Dashboard', name: 'Dashboard',
@ -59,6 +65,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import('@/views/admin/metadata-schema/index.vue'), component: () => import('@/views/admin/metadata-schema/index.vue'),
meta: { title: '元数据模式配置' } meta: { title: '元数据模式配置' }
}, },
{
path: '/admin/slot-definitions',
name: 'SlotDefinition',
component: () => import('@/views/admin/slot-definition/index.vue'),
meta: { title: '槽位定义管理' }
},
{ {
path: '/admin/intent-rules', path: '/admin/intent-rules',
name: 'IntentRule', name: 'IntentRule',
@ -77,6 +89,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import('@/views/admin/guardrail/index.vue'), component: () => import('@/views/admin/guardrail/index.vue'),
meta: { title: '输出护栏管理' } meta: { title: '输出护栏管理' }
}, },
{
path: '/admin/mid-platform-playground',
name: 'MidPlatformPlayground',
component: () => import('@/views/admin/mid-platform-playground/index.vue'),
meta: { title: '中台联调工作台' }
},
{ {
path: '/admin/decomposition-templates', path: '/admin/decomposition-templates',
name: 'DecompositionTemplate', name: 'DecompositionTemplate',

View File

@ -0,0 +1,71 @@
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'
export interface SlotDefinition {
id: string
tenant_id: string
slot_key: string
type: SlotType
required: boolean
extract_strategy?: ExtractStrategy
validation_rule?: string
ask_back_prompt?: string
default_value?: string | number | boolean | string[]
linked_field_id?: string
linked_field?: LinkedField
created_at?: string
updated_at?: string
}
export interface LinkedField {
id: string
field_key: string
label: string
type: string
field_roles: string[]
}
export interface SlotDefinitionCreateRequest {
tenant_id?: string
slot_key: string
type: SlotType
required: boolean
extract_strategy?: ExtractStrategy
validation_rule?: string
ask_back_prompt?: string
default_value?: string | number | boolean | string[]
linked_field_id?: string
}
export interface SlotDefinitionUpdateRequest {
type?: SlotType
required?: boolean
extract_strategy?: ExtractStrategy
validation_rule?: string
ask_back_prompt?: string
default_value?: string | number | boolean | string[]
linked_field_id?: string
}
export interface RuntimeSlotValue {
key: string
value: string | number | boolean | string[] | undefined
source: SlotSource
confidence: number
updated_at?: string
}
export const SLOT_TYPE_OPTIONS = [
{ value: 'string', label: '文本' },
{ value: 'number', label: '数字' },
{ value: 'boolean', label: '布尔值' },
{ value: 'enum', label: '单选枚举' },
{ value: 'array_enum', label: '多选枚举' }
]
export const EXTRACT_STRATEGY_OPTIONS = [
{ value: 'rule', label: '规则提取', description: '通过预定义规则从对话中提取' },
{ value: 'llm', label: 'LLM 推断', description: '通过大语言模型推断槽位值' },
{ value: 'user_input', label: '用户输入', description: '通过追问提示语让用户主动输入' }
]

View File

@ -0,0 +1,470 @@
<template>
<div class="slot-definition-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">槽位定义管理</h1>
<p class="page-desc">配置对话流程中的结构化槽位用于信息收集和槽位填充[AC-MRS-07,08]</p>
</div>
<div class="header-actions">
<el-select v-model="filterRequired" placeholder="按必填筛选" clearable style="width: 140px;">
<el-option label="必填" :value="true" />
<el-option label="可选" :value="false" />
</el-select>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建槽位
</el-button>
</div>
</div>
</div>
<el-card shadow="hover" class="slot-card" v-loading="loading">
<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>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" type="info">{{ getTypeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="required" label="必填" width="80">
<template #default="{ row }">
<el-tag :type="row.required ? 'danger' : 'info'" size="small">
{{ row.required ? '必填' : '可选' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="extract_strategy" label="提取策略" width="120">
<template #default="{ row }">
<el-tag v-if="row.extract_strategy" size="small">
{{ getExtractStrategyLabel(row.extract_strategy) }}
</el-tag>
<span v-else class="no-value">-</span>
</template>
</el-table-column>
<el-table-column prop="linked_field" label="关联字段" min-width="160">
<template #default="{ row }">
<div v-if="row.linked_field" class="linked-field">
<code class="field-key">{{ row.linked_field.field_key }}</code>
<el-tag size="small" type="success" class="linked-tag">已关联</el-tag>
</div>
<span v-else class="no-value">-</span>
</template>
</el-table-column>
<el-table-column prop="ask_back_prompt" label="追问提示语" min-width="180">
<template #default="{ row }">
<span v-if="row.ask_back_prompt" class="ask-back-prompt">{{ row.ask_back_prompt }}</span>
<span v-else class="no-value">-</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 && slots.length === 0" description="暂无槽位定义" />
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑槽位定义' : '新建槽位定义'"
width="650px"
: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="slot_key">
<el-input
v-model="formData.slot_key"
placeholder="如grade, subject"
:disabled="isEdit"
/>
<div class="field-hint">仅允许小写字母数字下划线</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="槽位类型" prop="type">
<el-select v-model="formData.type" style="width: 100%;">
<el-option
v-for="opt in SLOT_TYPE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</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-option
v-for="opt in EXTRACT_STRATEGY_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
>
<div class="extract-option">
<span>{{ opt.label }}</span>
<span class="extract-desc">{{ opt.description }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="关联字段" prop="linked_field_id">
<el-select
v-model="formData.linked_field_id"
style="width: 100%;"
clearable
filterable
placeholder="选择关联的元数据字段"
>
<el-option
v-for="field in slotFields"
:key="field.id"
:label="`${field.label} (${field.field_key})`"
:value="field.id"
>
<div class="field-option">
<span class="field-label">{{ field.label }}</span>
<code class="field-key-small">{{ field.field_key }}</code>
</div>
</el-option>
</el-select>
<div class="field-hint">关联字段后槽位值可同步到元数据字段</div>
</el-form-item>
<el-form-item label="校验规则" prop="validation_rule">
<el-input
v-model="formData.validation_rule"
type="textarea"
:rows="2"
placeholder="正则表达式或 JSON Schema 格式"
/>
</el-form-item>
<el-form-item label="追问提示语" prop="ask_back_prompt">
<el-input
v-model="formData.ask_back_prompt"
type="textarea"
:rows="2"
placeholder="当槽位缺失时,系统将使用此提示语追问用户"
/>
</el-form-item>
<el-form-item label="默认值" prop="default_value">
<el-input v-model="formData.default_value" placeholder="可选默认值" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { slotDefinitionApi } from '@/api/slot-definition'
import { metadataSchemaApi } from '@/api/metadata-schema'
import {
SLOT_TYPE_OPTIONS,
EXTRACT_STRATEGY_OPTIONS,
type SlotDefinition,
type SlotDefinitionCreateRequest,
type SlotDefinitionUpdateRequest,
type SlotType,
type ExtractStrategy
} from '@/types/slot-definition'
import type { MetadataFieldDefinition } from '@/types/metadata'
const loading = ref(false)
const slots = ref<SlotDefinition[]>([])
const slotFields = ref<MetadataFieldDefinition[]>([])
const filterRequired = ref<boolean | undefined>(undefined)
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()
const formData = reactive({
id: '',
slot_key: '',
type: 'string' as SlotType,
required: false,
extract_strategy: '' as ExtractStrategy | '',
validation_rule: '',
ask_back_prompt: '',
default_value: '',
linked_field_id: ''
})
const formRules: FormRules = {
slot_key: [
{ required: true, message: '请输入槽位标识', trigger: 'blur' },
{ pattern: /^[a-z][a-z0-9_]*$/, message: '以小写字母开头,仅允许小写字母、数字、下划线', trigger: 'blur' }
],
type: [{ required: true, message: '请选择槽位类型', trigger: 'change' }],
required: [{ required: true, message: '请选择是否必填', trigger: 'change' }]
}
const getTypeLabel = (type: SlotType) => {
return SLOT_TYPE_OPTIONS.find(o => o.value === type)?.label || type
}
const getExtractStrategyLabel = (strategy: ExtractStrategy) => {
return EXTRACT_STRATEGY_OPTIONS.find(o => o.value === strategy)?.label || strategy
}
const fetchSlots = async () => {
loading.value = true
try {
const res = await slotDefinitionApi.list(filterRequired.value)
slots.value = res || []
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取槽位定义失败')
} finally {
loading.value = false
}
}
const fetchSlotFields = async () => {
try {
const res = await metadataSchemaApi.getByRole('slot', true)
slotFields.value = res.items || []
} catch (error: any) {
console.error('获取槽位角色字段失败', error)
}
}
const handleCreate = () => {
isEdit.value = false
Object.assign(formData, {
id: '',
slot_key: '',
type: 'string',
required: false,
extract_strategy: '',
validation_rule: '',
ask_back_prompt: '',
default_value: '',
linked_field_id: ''
})
dialogVisible.value = true
}
const handleEdit = (slot: SlotDefinition) => {
isEdit.value = true
Object.assign(formData, {
id: slot.id,
slot_key: slot.slot_key,
type: slot.type,
required: slot.required,
extract_strategy: slot.extract_strategy || '',
validation_rule: slot.validation_rule || '',
ask_back_prompt: slot.ask_back_prompt || '',
default_value: slot.default_value ?? '',
linked_field_id: slot.linked_field_id || ''
})
dialogVisible.value = true
}
const handleDelete = async (slot: SlotDefinition) => {
try {
await ElMessageBox.confirm(
`确定要删除槽位「${slot.slot_key}」吗?[AC-MRS-16]`,
'删除确认',
{ type: 'warning' }
)
await slotDefinitionApi.delete(slot.id)
ElMessage.success('删除成功')
fetchSlots()
} 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: SlotDefinitionCreateRequest | SlotDefinitionUpdateRequest = {
slot_key: formData.slot_key,
type: formData.type,
required: formData.required,
extract_strategy: formData.extract_strategy || undefined,
validation_rule: formData.validation_rule || undefined,
ask_back_prompt: formData.ask_back_prompt || undefined,
linked_field_id: formData.linked_field_id || undefined
}
if (formData.default_value !== '') {
data.default_value = formData.default_value
}
if (isEdit.value) {
await slotDefinitionApi.update(formData.id, data as SlotDefinitionUpdateRequest)
ElMessage.success('更新成功')
} else {
await slotDefinitionApi.create(data as SlotDefinitionCreateRequest)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchSlots()
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '操作失败')
} finally {
submitting.value = false
}
})
}
watch(filterRequired, () => {
fetchSlots()
})
onMounted(() => {
fetchSlots()
fetchSlotFields()
})
</script>
<style scoped lang="scss">
.slot-definition-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;
}
.slot-card {
border-radius: 8px;
}
.slot-key {
padding: 2px 6px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
.no-value {
color: var(--el-text-color-placeholder);
}
.linked-field {
display: flex;
align-items: center;
gap: 8px;
.linked-tag {
margin-left: 4px;
}
}
.ask-back-prompt {
color: var(--el-text-color-regular);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
max-width: 180px;
}
.field-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.extract-option {
display: flex;
flex-direction: column;
gap: 2px;
.extract-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.field-option {
display: flex;
align-items: center;
gap: 8px;
.field-label {
font-weight: 500;
}
.field-key-small {
font-size: 11px;
padding: 1px 4px;
background-color: var(--el-fill-color-light);
border-radius: 3px;
font-family: monospace;
}
}
</style>