feat: implement metadata field definition with status governance [AC-IDSMETA-13, AC-IDSMETA-14]
This commit is contained in:
parent
e179abd0e5
commit
c432f457b8
|
|
@ -0,0 +1,59 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
DecompositionTemplate,
|
||||
DecompositionTemplateDetail,
|
||||
DecompositionTemplateCreate,
|
||||
DecompositionTemplateUpdate,
|
||||
DecompositionTemplateListResponse
|
||||
} from '@/types/decomposition-template'
|
||||
|
||||
export const decompositionTemplateApi = {
|
||||
list: (params?: { scene?: string; status?: string }) =>
|
||||
request<DecompositionTemplateListResponse>({
|
||||
method: 'GET',
|
||||
url: '/admin/decomposition-templates',
|
||||
params
|
||||
}),
|
||||
|
||||
get: (id: string) =>
|
||||
request<DecompositionTemplateDetail>({
|
||||
method: 'GET',
|
||||
url: `/admin/decomposition-templates/${id}`
|
||||
}),
|
||||
|
||||
create: (data: DecompositionTemplateCreate) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: '/admin/decomposition-templates',
|
||||
data
|
||||
}),
|
||||
|
||||
update: (id: string, data: DecompositionTemplateUpdate) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'PUT',
|
||||
url: `/admin/decomposition-templates/${id}`,
|
||||
data
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request({ method: 'DELETE', url: `/admin/decomposition-templates/${id}` }),
|
||||
|
||||
activate: (id: string) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: `/admin/decomposition-templates/${id}/activate`
|
||||
}),
|
||||
|
||||
archive: (id: string) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: `/admin/decomposition-templates/${id}/archive`
|
||||
}),
|
||||
|
||||
rollback: (id: string, version: number) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: `/admin/decomposition-templates/${id}/rollback`,
|
||||
data: { version }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
MetadataFieldDefinition,
|
||||
MetadataFieldCreateRequest,
|
||||
MetadataFieldUpdateRequest,
|
||||
MetadataFieldListResponse,
|
||||
MetadataPayload,
|
||||
MetadataScope
|
||||
} from '@/types/metadata'
|
||||
|
||||
export const metadataSchemaApi = {
|
||||
list: (status?: 'draft' | 'active' | 'deprecated') =>
|
||||
request<MetadataFieldListResponse>({ method: 'GET', url: '/admin/metadata-schemas', params: status ? { status } : {} }),
|
||||
|
||||
get: (id: string) =>
|
||||
request<MetadataFieldDefinition>({ method: 'GET', url: `/admin/metadata-schemas/${id}` }),
|
||||
|
||||
create: (data: MetadataFieldCreateRequest) =>
|
||||
request<MetadataFieldDefinition>({ method: 'POST', url: '/admin/metadata-schemas', data }),
|
||||
|
||||
update: (id: string, data: MetadataFieldUpdateRequest) =>
|
||||
request<MetadataFieldDefinition>({ method: 'PUT', url: `/admin/metadata-schemas/${id}`, data }),
|
||||
|
||||
delete: (id: string) =>
|
||||
request({ method: 'DELETE', url: `/admin/metadata-schemas/${id}` }),
|
||||
|
||||
getByScope: (scope: MetadataScope, includeDeprecated = false) =>
|
||||
request<MetadataFieldListResponse>({
|
||||
method: 'GET',
|
||||
url: '/admin/metadata-schemas',
|
||||
params: { scope, include_deprecated: includeDeprecated }
|
||||
}),
|
||||
|
||||
validate: (metadata: MetadataPayload, scope?: MetadataScope) =>
|
||||
request<{ valid: boolean; errors?: { field_key: string; message: string }[] }>({
|
||||
method: 'POST',
|
||||
url: '/admin/metadata-schemas/validate',
|
||||
data: { metadata, scope }
|
||||
}),
|
||||
|
||||
checkCompatibility: (oldScope: MetadataScope, newScope: MetadataScope, metadata: MetadataPayload) =>
|
||||
request<{
|
||||
compatible: boolean;
|
||||
conflicts: { field_key: string; reason: string }[];
|
||||
preserved_keys: string[]
|
||||
}>({
|
||||
method: 'POST',
|
||||
url: '/admin/metadata-schemas/check-compatibility',
|
||||
data: { old_scope: oldScope, new_scope: newScope, metadata }
|
||||
})
|
||||
}
|
||||
|
||||
export type {
|
||||
MetadataFieldDefinition,
|
||||
MetadataFieldCreateRequest,
|
||||
MetadataFieldUpdateRequest,
|
||||
MetadataFieldListResponse,
|
||||
MetadataPayload
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<div class="metadata-field-renderer">
|
||||
<el-form-item
|
||||
:label="field.label"
|
||||
:prop="propPath"
|
||||
:required="field.required && !isDeprecated"
|
||||
:class="{ 'is-deprecated': isDeprecated }"
|
||||
>
|
||||
<template #label>
|
||||
<div class="field-label-wrapper">
|
||||
<span>{{ field.label }}</span>
|
||||
<el-tag v-if="isDeprecated" type="danger" size="small" class="deprecated-tag">
|
||||
已废弃
|
||||
</el-tag>
|
||||
<el-tooltip v-if="field.description" :content="field.description" placement="top">
|
||||
<el-icon class="field-help"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isDeprecated" class="deprecated-notice">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
此字段已废弃,仅保留历史数据展示,不可编辑
|
||||
</template>
|
||||
</el-alert>
|
||||
<div class="deprecated-value" v-if="modelValue !== undefined && modelValue !== null && modelValue !== ''">
|
||||
当前值: {{ formatValue(modelValue) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<el-input
|
||||
v-if="field.type === 'string'"
|
||||
:model-value="modelValue as string"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="modelValue as number"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
<el-switch
|
||||
v-else-if="field.type === 'boolean'"
|
||||
:model-value="modelValue as boolean"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-else-if="field.type === 'enum'"
|
||||
:model-value="modelValue as string"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-else-if="field.type === 'array_enum'"
|
||||
:model-value="modelValue as string[]"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
multiple
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<div v-if="fieldHint" class="field-hint">{{ fieldHint }}</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import type { MetadataFieldDefinition, MetadataPayload } from '@/types/metadata'
|
||||
|
||||
const props = defineProps<{
|
||||
field: MetadataFieldDefinition
|
||||
modelValue: string | number | boolean | string[] | undefined
|
||||
propPath?: string
|
||||
disabled?: boolean
|
||||
isNewObject?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number | boolean | string[] | undefined): void
|
||||
}>()
|
||||
|
||||
const isDeprecated = computed(() => props.field.status === 'deprecated')
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (props.field.default !== undefined) {
|
||||
return `默认: ${props.field.default}`
|
||||
}
|
||||
return `请输入${props.field.label}`
|
||||
})
|
||||
|
||||
const fieldHint = computed(() => {
|
||||
const hints: string[] = []
|
||||
if (props.field.is_filterable) {
|
||||
hints.push('可作为过滤条件')
|
||||
}
|
||||
if (props.field.is_rank_feature) {
|
||||
hints.push('可作为排序特征')
|
||||
}
|
||||
return hints.join(' | ')
|
||||
})
|
||||
|
||||
const formatValue = (value: string | number | boolean | string[] | undefined): string => {
|
||||
if (value === undefined || value === null) return '无'
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metadata-field-renderer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-label-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deprecated-tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.is-deprecated :deep(.el-form-item__label) {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.deprecated-notice {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.deprecated-value {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
<template>
|
||||
<div class="metadata-form">
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<el-skeleton :rows="3" animated />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="allFields.length === 0" class="empty-fields">
|
||||
<el-empty description="暂无适用的元数据字段" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<el-row :gutter="16">
|
||||
<el-col
|
||||
v-for="field in visibleFields"
|
||||
:key="field.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="colSpan"
|
||||
>
|
||||
<MetadataFieldRenderer
|
||||
:field="field"
|
||||
:model-value="localMetadata[field.field_key]"
|
||||
:prop-path="`metadata.${field.field_key}`"
|
||||
:disabled="disabled || (field.status === 'deprecated' && !showDeprecatedEditable)"
|
||||
:is-new-object="isNewObject"
|
||||
@update:model-value="handleFieldUpdate(field.field_key, $event)"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div v-if="deprecatedFields.length > 0 && showDeprecated" class="deprecated-section">
|
||||
<el-divider content-position="left">
|
||||
<el-tag type="danger" size="small">已废弃字段</el-tag>
|
||||
</el-divider>
|
||||
<el-row :gutter="16">
|
||||
<el-col
|
||||
v-for="field in deprecatedFields"
|
||||
:key="field.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="colSpan"
|
||||
>
|
||||
<MetadataFieldRenderer
|
||||
:field="field"
|
||||
:model-value="localMetadata[field.field_key]"
|
||||
:prop-path="`metadata.${field.field_key}`"
|
||||
disabled
|
||||
:is-new-object="isNewObject"
|
||||
@update:model-value="handleFieldUpdate(field.field_key, $event)"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MetadataFieldRenderer from './MetadataFieldRenderer.vue'
|
||||
import { metadataSchemaApi } from '@/api/metadata-schema'
|
||||
import type { MetadataFieldDefinition, MetadataPayload, MetadataScope } from '@/types/metadata'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scope: MetadataScope
|
||||
modelValue?: MetadataPayload
|
||||
disabled?: boolean
|
||||
isNewObject?: boolean
|
||||
showDeprecated?: boolean
|
||||
showDeprecatedEditable?: boolean
|
||||
colSpan?: number
|
||||
}>(), {
|
||||
modelValue: () => ({}),
|
||||
disabled: false,
|
||||
isNewObject: true,
|
||||
showDeprecated: true,
|
||||
showDeprecatedEditable: false,
|
||||
colSpan: 8
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: MetadataPayload): void
|
||||
(e: 'fields-loaded', fields: MetadataFieldDefinition[]): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const allFields = ref<MetadataFieldDefinition[]>([])
|
||||
const localMetadata = ref<MetadataPayload>({})
|
||||
|
||||
const activeFields = computed(() => {
|
||||
return allFields.value.filter(f => f.status === 'active')
|
||||
})
|
||||
|
||||
const deprecatedFields = computed(() => {
|
||||
return allFields.value.filter(f => f.status === 'deprecated')
|
||||
})
|
||||
|
||||
const visibleFields = computed(() => {
|
||||
if (props.isNewObject) {
|
||||
return activeFields.value
|
||||
}
|
||||
return allFields.value.filter(f => f.status !== 'draft')
|
||||
})
|
||||
|
||||
const loadFields = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await metadataSchemaApi.getByScope(props.scope, props.showDeprecated)
|
||||
allFields.value = res.items || []
|
||||
emit('fields-loaded', allFields.value)
|
||||
|
||||
applyDefaults()
|
||||
} catch (error: any) {
|
||||
console.error('加载元数据字段失败', error)
|
||||
ElMessage.error('加载元数据字段失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyDefaults = () => {
|
||||
const defaults: MetadataPayload = {}
|
||||
activeFields.value.forEach(field => {
|
||||
if (field.default !== undefined && localMetadata.value[field.field_key] === undefined) {
|
||||
defaults[field.field_key] = field.default
|
||||
}
|
||||
})
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
localMetadata.value = { ...defaults, ...localMetadata.value }
|
||||
emit('update:modelValue', { ...localMetadata.value })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFieldUpdate = (fieldKey: string, value: string | number | boolean | string[] | undefined) => {
|
||||
localMetadata.value[fieldKey] = value
|
||||
emit('update:modelValue', { ...localMetadata.value })
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal) {
|
||||
localMetadata.value = { ...newVal }
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
watch(() => props.scope, () => {
|
||||
loadFields()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadFields()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
validate: async () => {
|
||||
const errors: { field_key: string; message: string }[] = []
|
||||
|
||||
activeFields.value.forEach(field => {
|
||||
if (field.required) {
|
||||
const value = localMetadata.value[field.field_key]
|
||||
if (value === undefined || value === null || value === '' ||
|
||||
(Array.isArray(value) && value.length === 0)) {
|
||||
errors.push({
|
||||
field_key: field.field_key,
|
||||
message: `${field.label} 为必填项`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'enum' || field.type === 'array_enum') {
|
||||
const value = localMetadata.value[field.field_key]
|
||||
if (value !== undefined && value !== null && field.options) {
|
||||
if (field.type === 'enum' && !field.options.includes(value as string)) {
|
||||
errors.push({
|
||||
field_key: field.field_key,
|
||||
message: `${field.label} 的值不在有效选项中`
|
||||
})
|
||||
}
|
||||
if (field.type === 'array_enum' && Array.isArray(value)) {
|
||||
const invalidValues = value.filter(v => !field.options!.includes(v))
|
||||
if (invalidValues.length > 0) {
|
||||
errors.push({
|
||||
field_key: field.field_key,
|
||||
message: `${field.label} 包含无效选项: ${invalidValues.join(', ')}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
},
|
||||
getMetadata: () => ({ ...localMetadata.value }),
|
||||
getFields: () => allFields.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metadata-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-fields {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deprecated-section {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="类型切换确认"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="type-change-handler">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="change-alert"
|
||||
>
|
||||
<template #title>
|
||||
检测到类型变更,部分元数据字段可能需要处理
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div v-if="conflicts.length > 0" class="conflicts-section">
|
||||
<h4 class="section-title">需要处理的字段</h4>
|
||||
<el-table :data="conflicts" stripe size="small">
|
||||
<el-table-column prop="label" label="字段名" width="120" />
|
||||
<el-table-column label="当前值" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatValue(row.old_value) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="冲突原因" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.conflict_type === 'removed' ? 'danger' : 'warning'" size="small">
|
||||
{{ row.conflict_type === 'removed' ? '字段不存在' : '类型不匹配' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理方式" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.action" size="small" style="width: 100%">
|
||||
<el-option label="移除" value="remove" />
|
||||
<el-option
|
||||
v-if="row.map_to"
|
||||
:label="`映射到 ${row.map_to}`"
|
||||
value="map"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-if="preserved.length > 0" class="preserved-section">
|
||||
<h4 class="section-title">
|
||||
<el-icon class="success-icon"><CircleCheckFilled /></el-icon>
|
||||
保留的字段 ({{ preserved.length }})
|
||||
</h4>
|
||||
<div class="preserved-tags">
|
||||
<el-tag
|
||||
v-for="item in preserved"
|
||||
:key="item.field_key"
|
||||
type="success"
|
||||
size="small"
|
||||
class="preserved-tag"
|
||||
>
|
||||
{{ item.field_key }}: {{ formatValue(item.value) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消切换</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">
|
||||
确认切换
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue'
|
||||
import type { MetadataFieldDefinition, MetadataPayload, MetadataScope, TypeChangeConflict } from '@/types/metadata'
|
||||
|
||||
interface ConflictItem extends TypeChangeConflict {
|
||||
action: 'remove' | 'map'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
oldScope: MetadataScope
|
||||
newScope: MetadataScope
|
||||
currentMetadata: MetadataPayload
|
||||
oldFields: MetadataFieldDefinition[]
|
||||
newFields: MetadataFieldDefinition[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'confirm', result: { metadata: MetadataPayload; removed: string[] }): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
const conflicts = ref<ConflictItem[]>([])
|
||||
const preserved = ref<{ field_key: string; value: string | number | boolean | string[] | undefined }[]>([])
|
||||
|
||||
const analyzeCompatibility = () => {
|
||||
conflicts.value = []
|
||||
preserved.value = []
|
||||
|
||||
const newFieldKeys = new Set(props.newFields.map(f => f.field_key))
|
||||
const newFieldTypes = new Map(props.newFields.map(f => [f.field_key, f.type]))
|
||||
|
||||
Object.entries(props.currentMetadata).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) return
|
||||
|
||||
if (!newFieldKeys.has(key)) {
|
||||
const oldField = props.oldFields.find(f => f.field_key === key)
|
||||
conflicts.value.push({
|
||||
field_key: key,
|
||||
label: oldField?.label || key,
|
||||
conflict_type: 'removed',
|
||||
old_value: value,
|
||||
suggested_action: 'remove',
|
||||
action: 'remove'
|
||||
})
|
||||
} else {
|
||||
const newType = newFieldTypes.get(key)
|
||||
const oldField = props.oldFields.find(f => f.field_key === key)
|
||||
const oldType = oldField?.type
|
||||
|
||||
if (oldType !== newType) {
|
||||
conflicts.value.push({
|
||||
field_key: key,
|
||||
label: oldField?.label || key,
|
||||
conflict_type: 'type_mismatch',
|
||||
old_value: value,
|
||||
suggested_action: 'remove',
|
||||
action: 'remove'
|
||||
})
|
||||
} else {
|
||||
preserved.value.push({ field_key: key, value })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatValue = (value: string | number | boolean | string[] | undefined): string => {
|
||||
if (value === undefined || value === null) return '无'
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const newMetadata: MetadataPayload = {}
|
||||
const removed: string[] = []
|
||||
|
||||
preserved.value.forEach(item => {
|
||||
newMetadata[item.field_key] = item.value
|
||||
})
|
||||
|
||||
conflicts.value.forEach(item => {
|
||||
if (item.action === 'remove') {
|
||||
removed.push(item.field_key)
|
||||
} else if (item.action === 'map' && item.map_to) {
|
||||
newMetadata[item.map_to] = item.old_value
|
||||
}
|
||||
})
|
||||
|
||||
emit('confirm', { metadata: newMetadata, removed })
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(() => props.show, (show) => {
|
||||
if (show) {
|
||||
analyzeCompatibility()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.type-change-handler {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.change-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 16px 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.conflicts-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preserved-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preserved-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preserved-tag {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as MetadataForm } from './MetadataForm.vue'
|
||||
export { default as MetadataFieldRenderer } from './MetadataFieldRenderer.vue'
|
||||
export { default as TypeChangeHandler } from './TypeChangeHandler.vue'
|
||||
|
|
@ -53,6 +53,12 @@ const routes: Array<RouteRecordRaw> = [
|
|||
component: () => import('@/views/admin/knowledge-base/index.vue'),
|
||||
meta: { title: '多知识库管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/metadata-schemas',
|
||||
name: 'MetadataSchema',
|
||||
component: () => import('@/views/admin/metadata-schema/index.vue'),
|
||||
meta: { title: '元数据模式配置' }
|
||||
},
|
||||
{
|
||||
path: '/admin/intent-rules',
|
||||
name: 'IntentRule',
|
||||
|
|
@ -71,6 +77,12 @@ const routes: Array<RouteRecordRaw> = [
|
|||
component: () => import('@/views/admin/guardrail/index.vue'),
|
||||
meta: { title: '输出护栏管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/decomposition-templates',
|
||||
name: 'DecompositionTemplate',
|
||||
component: () => import('@/views/admin/decomposition-template/index.vue'),
|
||||
meta: { title: '拆解模板管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/intent-rules',
|
||||
name: 'IntentRuleMonitoring',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface DecompositionTemplate {
|
||||
id: string
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
current_version: number
|
||||
status: DecompositionStatus
|
||||
is_latest_effective: boolean
|
||||
effective_at?: string
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateDetail {
|
||||
id: string
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
current_version: number
|
||||
status: DecompositionStatus
|
||||
is_latest_effective: boolean
|
||||
effective_at?: string
|
||||
steps: DecompositionStep[]
|
||||
versions: DecompositionVersion[]
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type DecompositionStatus = 'draft' | 'active' | 'archived'
|
||||
|
||||
export interface DecompositionStep {
|
||||
step_id: string
|
||||
step_no: number
|
||||
instruction: string
|
||||
expected_output?: string
|
||||
dependencies?: string[]
|
||||
}
|
||||
|
||||
export interface DecompositionVersion {
|
||||
version: number
|
||||
status: DecompositionStatus
|
||||
steps: DecompositionStep[]
|
||||
created_at: string
|
||||
effective_at?: string
|
||||
archived_at?: string
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateCreate {
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
steps: DecompositionStep[]
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateUpdate {
|
||||
name?: string
|
||||
scene?: string
|
||||
description?: string
|
||||
steps?: DecompositionStep[]
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateListResponse {
|
||||
data: DecompositionTemplate[]
|
||||
}
|
||||
|
||||
export const DECOMPOSITION_STATUS_OPTIONS = [
|
||||
{ value: 'draft', label: '草稿', color: 'info' },
|
||||
{ value: 'active', label: '生效', color: 'success' },
|
||||
{ value: 'archived', label: '归档', color: 'warning' }
|
||||
]
|
||||
|
||||
export const DECOMPOSITION_SCENE_OPTIONS = [
|
||||
{ value: 'customer_service', label: '客服场景' },
|
||||
{ value: 'sales', label: '销售场景' },
|
||||
{ value: 'support', label: '技术支持' },
|
||||
{ value: 'complaint', label: '投诉处理' },
|
||||
{ value: 'general', label: '通用场景' }
|
||||
]
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface IntentRule {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -11,6 +13,7 @@ export interface IntentRule {
|
|||
transfer_message?: string
|
||||
hit_count: number
|
||||
is_enabled: boolean
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -26,6 +29,7 @@ export interface IntentRuleCreate {
|
|||
flow_id?: string
|
||||
transfer_message?: string
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface IntentRuleUpdate {
|
||||
|
|
@ -39,6 +43,7 @@ export interface IntentRuleUpdate {
|
|||
flow_id?: string
|
||||
transfer_message?: string
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface IntentRuleListResponse {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
export type MetadataFieldType = 'string' | 'number' | 'boolean' | 'enum' | 'array_enum'
|
||||
export type MetadataFieldStatus = 'draft' | 'active' | 'deprecated'
|
||||
export type MetadataScope = 'kb_document' | 'intent_rule' | 'script_flow' | 'prompt_template'
|
||||
|
||||
export interface MetadataFieldDefinition {
|
||||
id: string
|
||||
field_key: string
|
||||
label: string
|
||||
type: MetadataFieldType
|
||||
description?: string
|
||||
required: boolean
|
||||
options?: string[]
|
||||
default?: string | number | boolean
|
||||
scope: MetadataScope[]
|
||||
is_filterable: boolean
|
||||
is_rank_feature: boolean
|
||||
status: MetadataFieldStatus
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface MetadataFieldCreateRequest {
|
||||
field_key: string
|
||||
label: string
|
||||
type: MetadataFieldType
|
||||
required: boolean
|
||||
options?: string[]
|
||||
default?: string | number | boolean
|
||||
scope: MetadataScope[]
|
||||
is_filterable?: boolean
|
||||
is_rank_feature?: boolean
|
||||
status: MetadataFieldStatus
|
||||
}
|
||||
|
||||
export interface MetadataFieldUpdateRequest {
|
||||
label?: string
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
default?: string | number | boolean
|
||||
scope?: MetadataScope[]
|
||||
is_filterable?: boolean
|
||||
is_rank_feature?: boolean
|
||||
status?: MetadataFieldStatus
|
||||
}
|
||||
|
||||
export interface MetadataFieldListResponse {
|
||||
items: MetadataFieldDefinition[]
|
||||
}
|
||||
|
||||
export interface MetadataPayload {
|
||||
[key: string]: string | number | boolean | string[] | undefined
|
||||
}
|
||||
|
||||
export interface TypeChangeConflict {
|
||||
field_key: string
|
||||
label: string
|
||||
conflict_type: 'removed' | 'type_mismatch'
|
||||
old_value?: string | number | boolean | string[]
|
||||
suggested_action: 'remove' | 'map'
|
||||
map_to?: string
|
||||
}
|
||||
|
||||
export interface TypeChangeResult {
|
||||
preserved: { field_key: string; value: string | number | boolean | string[] | undefined }[]
|
||||
conflicts: TypeChangeConflict[]
|
||||
}
|
||||
|
||||
export const METADATA_STATUS_OPTIONS = [
|
||||
{ value: 'draft', label: '草稿', color: 'info', description: '字段可编辑,不可用于新建对象' },
|
||||
{ value: 'active', label: '生效', color: 'success', description: '可用于新建与编辑对象' },
|
||||
{ value: 'deprecated', label: '废弃', color: 'danger', description: '不可用于新建,历史数据可读' }
|
||||
]
|
||||
|
||||
export const METADATA_SCOPE_OPTIONS = [
|
||||
{ value: 'kb_document', label: '知识库文档' },
|
||||
{ value: 'intent_rule', label: '意图规则' },
|
||||
{ value: 'script_flow', label: '话术流程' },
|
||||
{ value: 'prompt_template', label: 'Prompt模板' }
|
||||
]
|
||||
|
||||
export const METADATA_TYPE_OPTIONS = [
|
||||
{ value: 'string', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'boolean', label: '布尔值' },
|
||||
{ value: 'enum', label: '单选枚举' },
|
||||
{ value: 'array_enum', label: '多选枚举' }
|
||||
]
|
||||
|
||||
export const STATUS_TAG_MAP: Record<MetadataFieldStatus, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
draft: 'info',
|
||||
active: 'success',
|
||||
deprecated: 'danger'
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
is_default: boolean
|
||||
metadata?: MetadataPayload
|
||||
published_version?: PromptVersionInfo
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
|
@ -24,6 +27,7 @@ export interface PromptTemplateDetail {
|
|||
current_content?: string
|
||||
variables?: PromptVariable[]
|
||||
versions?: PromptVersion[]
|
||||
metadata?: MetadataPayload
|
||||
published_version?: PromptVersionInfo
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
|
@ -51,6 +55,7 @@ export interface PromptTemplateCreate {
|
|||
system_instruction: string
|
||||
variables?: PromptVariable[]
|
||||
is_default?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface PromptTemplateUpdate {
|
||||
|
|
@ -59,6 +64,7 @@ export interface PromptTemplateUpdate {
|
|||
description?: string
|
||||
system_instruction?: string
|
||||
variables?: PromptVariable[]
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface PromptTemplateListResponse {
|
||||
|
|
@ -84,8 +90,11 @@ export const SCENE_OPTIONS = [
|
|||
|
||||
export const BUILTIN_VARIABLES: PromptVariable[] = [
|
||||
{ name: 'persona_name', description: 'AI 人设名称', default_value: 'AI助手' },
|
||||
{ name: 'persona_personality', description: 'AI 性格特点', default_value: '热情、耐心、专业' },
|
||||
{ name: 'persona_tone', description: 'AI 说话风格', default_value: '亲切自然,使用口语化表达' },
|
||||
{ name: 'brand_name', description: '品牌名称', default_value: '我们公司' },
|
||||
{ name: 'current_time', description: '当前时间' },
|
||||
{ name: 'channel_type', description: '渠道类型(web/wechat/app)' },
|
||||
{ name: 'channel_type', description: '渠道类型(web/wechat/phone/app)' },
|
||||
{ name: 'user_name', description: '用户名称' },
|
||||
{ name: 'context', description: '检索上下文' },
|
||||
{ name: 'query', description: '用户问题' },
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface ScriptFlow {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -5,6 +7,7 @@ export interface ScriptFlow {
|
|||
step_count: number
|
||||
is_enabled: boolean
|
||||
linked_rule_count: number
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -15,10 +18,13 @@ export interface ScriptFlowDetail {
|
|||
description?: string
|
||||
steps: FlowStep[]
|
||||
is_enabled: boolean
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ScriptMode = 'fixed' | 'flexible' | 'template'
|
||||
|
||||
export interface FlowStep {
|
||||
step_id: string
|
||||
step_no: number
|
||||
|
|
@ -27,11 +33,18 @@ export interface FlowStep {
|
|||
timeout_seconds?: number
|
||||
timeout_action?: 'repeat' | 'skip' | 'transfer'
|
||||
next_conditions?: NextCondition[]
|
||||
default_next?: number
|
||||
script_mode?: ScriptMode
|
||||
intent?: string
|
||||
intent_description?: string
|
||||
script_constraints?: string[]
|
||||
expected_variables?: string[]
|
||||
}
|
||||
|
||||
export interface NextCondition {
|
||||
keywords: string[]
|
||||
target_step_id: string
|
||||
keywords?: string[]
|
||||
pattern?: string
|
||||
goto_step: number
|
||||
}
|
||||
|
||||
export interface ScriptFlowCreate {
|
||||
|
|
@ -39,6 +52,7 @@ export interface ScriptFlowCreate {
|
|||
description?: string
|
||||
steps: FlowStep[]
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface ScriptFlowUpdate {
|
||||
|
|
@ -46,6 +60,7 @@ export interface ScriptFlowUpdate {
|
|||
description?: string
|
||||
steps?: FlowStep[]
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface ScriptFlowListResponse {
|
||||
|
|
@ -57,3 +72,17 @@ export const TIMEOUT_ACTION_OPTIONS = [
|
|||
{ value: 'skip', label: '跳过进入下一步' },
|
||||
{ value: 'transfer', label: '转人工' }
|
||||
]
|
||||
|
||||
export const SCRIPT_MODE_OPTIONS = [
|
||||
{ value: 'fixed' as const, label: '固定话术', description: '话术内容固定不变' },
|
||||
{ value: 'flexible' as const, label: '灵活话术', description: 'AI根据意图和上下文生成' },
|
||||
{ value: 'template' as const, label: '模板话术', description: 'AI填充模板中的变量' }
|
||||
]
|
||||
|
||||
export const PRESET_CONSTRAINTS = [
|
||||
'必须礼貌',
|
||||
'语气自然',
|
||||
'简洁明了',
|
||||
'不要生硬',
|
||||
'不要重复'
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,676 @@
|
|||
<template>
|
||||
<div class="decomposition-template-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">拆解模板管理</h1>
|
||||
<p class="page-desc">管理复杂问题的拆解模板,支持版本管理与生效标记。[AC-IDSMETA-22]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 130px;">
|
||||
<el-option
|
||||
v-for="opt in DECOMPOSITION_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 140px;">
|
||||
<el-option
|
||||
v-for="opt in DECOMPOSITION_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_latest_effective" type="success" size="small" class="effective-tag">
|
||||
最近生效
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scene" label="场景" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ getSceneLabel(row.scene) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_version" label="版本" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="version-badge">v{{ row.current_version }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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="effective_at" label="生效时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.effective_at">{{ formatDate(row.effective_at) }}</span>
|
||||
<span v-else class="no-date">-</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
|
||||
v-if="row.status === 'draft'"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click="handleActivate(row)"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
生效
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'active'"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
@click="handleArchive(row)"
|
||||
>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
归档
|
||||
</el-button>
|
||||
<el-button type="info" 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="850px"
|
||||
: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="场景" prop="scene">
|
||||
<el-select v-model="formData.scene" placeholder="请选择场景" style="width: 100%;">
|
||||
<el-option
|
||||
v-for="opt in DECOMPOSITION_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-divider content-position="left">拆解步骤</el-divider>
|
||||
|
||||
<div class="steps-editor">
|
||||
<div
|
||||
v-for="(step, index) in formData.steps"
|
||||
:key="step.step_id"
|
||||
class="step-item"
|
||||
>
|
||||
<div class="step-header">
|
||||
<span class="step-order">步骤 {{ index + 1 }}</span>
|
||||
<el-button type="danger" link size="small" @click="removeStep(index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form-item label="指令" required>
|
||||
<el-input
|
||||
v-model="step.instruction"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入该步骤的处理指令"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="期望输出">
|
||||
<el-input
|
||||
v-model="step.expected_output"
|
||||
placeholder="可选:描述该步骤期望的输出格式"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<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="detailDrawer" title="模板详情" size="600px" destroy-on-close>
|
||||
<div v-if="currentTemplate" class="detail-content">
|
||||
<div class="detail-header">
|
||||
<h3>{{ currentTemplate.name }}</h3>
|
||||
<div class="detail-tags">
|
||||
<el-tag :type="getStatusTagType(currentTemplate.status)" size="small">
|
||||
{{ getStatusLabel(currentTemplate.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="currentTemplate.is_latest_effective" type="success" size="small">
|
||||
最近生效
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="场景">{{ getSceneLabel(currentTemplate.scene) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本">v{{ currentTemplate.current_version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效时间">
|
||||
{{ currentTemplate.effective_at ? formatDate(currentTemplate.effective_at) : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(currentTemplate.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ currentTemplate.description || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">版本历史</el-divider>
|
||||
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="version in currentTemplate.versions"
|
||||
:key="version.version"
|
||||
:type="version.status === 'active' ? 'success' : 'info'"
|
||||
:timestamp="formatDate(version.created_at)"
|
||||
placement="top"
|
||||
>
|
||||
<div class="version-item">
|
||||
<div class="version-header">
|
||||
<span class="version-number">v{{ version.version }}</span>
|
||||
<el-tag :type="getStatusTagType(version.status)" size="small">
|
||||
{{ getStatusLabel(version.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="version-steps">
|
||||
<div v-for="(step, idx) in version.steps" :key="step.step_id" class="version-step">
|
||||
<span class="step-no">{{ idx + 1 }}.</span>
|
||||
<span class="step-instruction">{{ step.instruction }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="version.status === 'archived'"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="handleRollback(version.version)"
|
||||
>
|
||||
回滚到此版本
|
||||
</el-button>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Check, FolderOpened, View } from '@element-plus/icons-vue'
|
||||
import { decompositionTemplateApi } from '@/api/decomposition-template'
|
||||
import { DECOMPOSITION_STATUS_OPTIONS, DECOMPOSITION_SCENE_OPTIONS } from '@/types/decomposition-template'
|
||||
import type {
|
||||
DecompositionTemplate,
|
||||
DecompositionTemplateDetail,
|
||||
DecompositionTemplateCreate,
|
||||
DecompositionTemplateUpdate,
|
||||
DecompositionStatus
|
||||
} from '@/types/decomposition-template'
|
||||
|
||||
const loading = ref(false)
|
||||
const templates = ref<DecompositionTemplate[]>([])
|
||||
const filterStatus = ref('')
|
||||
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<DecompositionTemplateDetail | null>(null)
|
||||
const currentEditId = ref('')
|
||||
|
||||
const generateStepId = () => `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const formData = ref<DecompositionTemplateCreate>({
|
||||
name: '',
|
||||
scene: '',
|
||||
description: '',
|
||||
steps: [],
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
scene: [{ required: true, message: '请选择场景', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getSceneLabel = (scene: string) => {
|
||||
const opt = DECOMPOSITION_SCENE_OPTIONS.find(o => o.value === scene)
|
||||
return opt?.label || scene
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: DecompositionStatus) => {
|
||||
const opt = DECOMPOSITION_STATUS_OPTIONS.find(o => o.value === status)
|
||||
return opt?.label || status
|
||||
}
|
||||
|
||||
const getStatusTagType = (status: DecompositionStatus): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const typeMap: Record<DecompositionStatus, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
draft: 'info',
|
||||
active: 'success',
|
||||
archived: 'warning'
|
||||
}
|
||||
return typeMap[status] || '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 loadTemplates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await decompositionTemplateApi.list({
|
||||
status: filterStatus.value || undefined,
|
||||
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: '',
|
||||
steps: [],
|
||||
metadata: {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: DecompositionTemplate) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = row.id
|
||||
try {
|
||||
const detail = await decompositionTemplateApi.get(row.id)
|
||||
formData.value = {
|
||||
name: detail.name,
|
||||
scene: detail.scene,
|
||||
description: detail.description || '',
|
||||
steps: detail.steps || [],
|
||||
metadata: detail.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该模板吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await decompositionTemplateApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要将该模板设为生效状态吗?', '确认生效', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
})
|
||||
await decompositionTemplateApi.activate(row.id)
|
||||
ElMessage.success('已生效')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要归档该模板吗?', '确认归档', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await decompositionTemplateApi.archive(row.id)
|
||||
ElMessage.success('已归档')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
currentTemplate.value = await decompositionTemplateApi.get(row.id)
|
||||
detailDrawer.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async (version: number) => {
|
||||
if (!currentTemplate.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要回滚到版本 v${version} 吗?`, '确认回滚', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await decompositionTemplateApi.rollback(currentTemplate.value.id, version)
|
||||
ElMessage.success('回滚成功')
|
||||
currentTemplate.value = await decompositionTemplateApi.get(currentTemplate.value.id)
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('回滚失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addStep = () => {
|
||||
formData.value.steps.push({
|
||||
step_id: generateStepId(),
|
||||
step_no: formData.value.steps.length + 1,
|
||||
instruction: '',
|
||||
expected_output: '',
|
||||
dependencies: []
|
||||
})
|
||||
}
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
formData.value.steps.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.steps.length === 0) {
|
||||
ElMessage.warning('请至少添加一个步骤')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < formData.value.steps.length; i++) {
|
||||
if (!formData.value.steps[i].instruction?.trim()) {
|
||||
ElMessage.warning(`步骤 ${i + 1} 的指令不能为空`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const submitData = {
|
||||
...formData.value,
|
||||
steps: formData.value.steps.map((step, index) => ({
|
||||
...step,
|
||||
step_no: index + 1
|
||||
}))
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const updateData: DecompositionTemplateUpdate = submitData
|
||||
await decompositionTemplateApi.update(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await decompositionTemplateApi.create(submitData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([filterStatus, filterScene], () => {
|
||||
loadTemplates()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.decomposition-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;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.effective-tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-date {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.steps-editor {
|
||||
max-height: 350px;
|
||||
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;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-order {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.version-steps {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.version-step {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-no {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.step-instruction {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">意图规则管理</h1>
|
||||
<p class="page-desc">配置意图识别规则,让特定问题走固定回复或话术流程。</p>
|
||||
<p class="page-desc">配置意图识别规则,让特定问题走固定回复或话术流程。[AC-IDSMETA-16]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterResponseType" placeholder="按响应类型筛选" clearable style="width: 150px;">
|
||||
|
|
@ -48,6 +48,14 @@
|
|||
</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 label="元数据" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
|
||||
{{ Object.keys(row.metadata).length }} 个字段
|
||||
</el-tag>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
|
|
@ -79,7 +87,7 @@
|
|||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑规则' : '新建规则'"
|
||||
width="700px"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
|
|
@ -168,6 +176,16 @@
|
|||
</el-form-item>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="intent_rule"
|
||||
v-model="formData.metadata"
|
||||
:is-new-object="!isEdit"
|
||||
:col-span="12"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
|
|
@ -198,10 +216,12 @@ import {
|
|||
} from '@/api/intent-rule'
|
||||
import { listKnowledgeBases } from '@/api/knowledge-base'
|
||||
import { listScriptFlows } from '@/api/script-flow'
|
||||
import { MetadataForm } from '@/components/metadata'
|
||||
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 type { MetadataPayload } from '@/types/metadata'
|
||||
import KeywordInput from './components/KeywordInput.vue'
|
||||
import PatternInput from './components/PatternInput.vue'
|
||||
import TestDialog from './components/TestDialog.vue'
|
||||
|
|
@ -215,6 +235,7 @@ const dialogVisible = ref(false)
|
|||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const currentEditId = ref('')
|
||||
const testDialogVisible = ref(false)
|
||||
const testRuleId = ref('')
|
||||
|
|
@ -231,7 +252,8 @@ const defaultFormData = (): IntentRuleCreate => ({
|
|||
target_kb_ids: [],
|
||||
flow_id: '',
|
||||
transfer_message: '',
|
||||
is_enabled: true
|
||||
is_enabled: true,
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formData = ref<IntentRuleCreate>(defaultFormData())
|
||||
|
|
@ -308,7 +330,8 @@ const handleEdit = (row: IntentRule) => {
|
|||
target_kb_ids: row.target_kb_ids || [],
|
||||
flow_id: row.flow_id || '',
|
||||
transfer_message: row.transfer_message || '',
|
||||
is_enabled: row.is_enabled
|
||||
is_enabled: row.is_enabled,
|
||||
metadata: row.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
|
@ -361,6 +384,14 @@ const handleSubmit = async () => {
|
|||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
|
|
@ -370,7 +401,8 @@ const handleSubmit = async () => {
|
|||
patterns: formData.value.patterns,
|
||||
response_type: formData.value.response_type,
|
||||
priority: formData.value.priority,
|
||||
is_enabled: formData.value.is_enabled
|
||||
is_enabled: formData.value.is_enabled,
|
||||
metadata: formData.value.metadata
|
||||
}
|
||||
if (formData.value.response_type === 'fixed') {
|
||||
updateData.fixed_reply = formData.value.fixed_reply
|
||||
|
|
@ -464,6 +496,10 @@ onMounted(() => {
|
|||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
<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"
|
||||
<el-button type="primary" @click="handleUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传文档
|
||||
</el-button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
>
|
||||
<el-button type="primary">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传文档
|
||||
</el-button>
|
||||
</el-upload>
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="documents" v-loading="loading" stripe>
|
||||
|
|
@ -28,13 +23,36 @@
|
|||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="元数据" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="metadata-preview" v-if="row.metadata && Object.keys(row.metadata).length > 0">
|
||||
<el-tag
|
||||
v-for="(value, key) in getPreviewMetadata(row.metadata)"
|
||||
:key="key"
|
||||
size="small"
|
||||
type="info"
|
||||
class="metadata-tag"
|
||||
>
|
||||
{{ key }}: {{ formatMetadataValue(value) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="Object.keys(row.metadata).length > 3" size="small" type="info">
|
||||
+{{ Object.keys(row.metadata).length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</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">
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEditMetadata(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑元数据
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
|
|
@ -51,17 +69,82 @@
|
|||
@current-change="loadDocuments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="uploadDialogVisible"
|
||||
title="上传文档"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="uploadForm" :rules="uploadRules" ref="uploadFormRef" label-width="80px">
|
||||
<el-form-item label="文件">
|
||||
<div class="file-info" v-if="selectedFile">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ selectedFile.name }}</span>
|
||||
<el-tag size="small" type="info">{{ formatFileSize(selectedFile.size) }}</el-tag>
|
||||
</div>
|
||||
<el-button v-else @click="handleUploadClick">选择文件</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-15]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="kb_document"
|
||||
v-model="uploadForm.metadata"
|
||||
:is-new-object="true"
|
||||
:col-span="12"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="handleUpload">
|
||||
上传
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
title="编辑元数据"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<MetadataForm
|
||||
ref="editMetadataFormRef"
|
||||
scope="kb_document"
|
||||
v-model="editForm.metadata"
|
||||
:is-new-object="false"
|
||||
:show-deprecated="true"
|
||||
:col-span="12"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSaveMetadata">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</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 { Upload, Edit, Document } from '@element-plus/icons-vue'
|
||||
import { listDocuments, deleteDocument, getIndexJob } from '@/api/knowledge-base'
|
||||
import type { Document, IndexJob } from '@/types/knowledge-base'
|
||||
import { metadataSchemaApi } from '@/api/metadata-schema'
|
||||
import { MetadataForm } from '@/components/metadata'
|
||||
import type { Document as DocType, IndexJob } from '@/types/knowledge-base'
|
||||
import type { MetadataPayload } from '@/types/metadata'
|
||||
import { useTenantStore } from '@/stores/tenant'
|
||||
|
||||
interface DocumentWithMetadata extends DocType {
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
kbId: string
|
||||
}>()
|
||||
|
|
@ -72,7 +155,7 @@ const emit = defineEmits<{
|
|||
|
||||
const tenantStore = useTenantStore()
|
||||
const loading = ref(false)
|
||||
const documents = ref<Document[]>([])
|
||||
const documents = ref<DocumentWithMetadata[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
|
|
@ -81,14 +164,28 @@ const pagination = ref({
|
|||
totalPages: 0
|
||||
})
|
||||
|
||||
const uploadUrl = computed(() => {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
return `${baseUrl}/admin/kb/documents`
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const uploadDialogVisible = ref(false)
|
||||
const editDialogVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const saving = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const uploadFormRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const editMetadataFormRef = ref()
|
||||
const currentEditDoc = ref<DocumentWithMetadata | null>(null)
|
||||
|
||||
const uploadForm = ref({
|
||||
metadata: {} as MetadataPayload
|
||||
})
|
||||
|
||||
const uploadHeaders = computed(() => ({
|
||||
'X-Tenant-Id': tenantStore.currentTenantId
|
||||
}))
|
||||
const editForm = ref({
|
||||
metadata: {} as MetadataPayload
|
||||
})
|
||||
|
||||
const uploadRules = {
|
||||
file: [{ required: true, message: '请选择文件', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getStatusType = (status: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const typeMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
|
|
@ -122,6 +219,27 @@ const formatDate = (dateStr: string) => {
|
|||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const getPreviewMetadata = (metadata: MetadataPayload) => {
|
||||
const keys = Object.keys(metadata).slice(0, 3)
|
||||
const result: MetadataPayload = {}
|
||||
keys.forEach(key => {
|
||||
result[key] = metadata[key]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const formatMetadataValue = (value: string | number | boolean | string[] | undefined): string => {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const loadDocuments = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
|
|
@ -139,46 +257,128 @@ const loadDocuments = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
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 handleUploadClick = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const allowedExtensions = ['.txt', '.md', '.pdf', '.doc', '.docx', '.xls', '.xlsx']
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
ElMessage.error('不支持的文件格式')
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
ElMessage.error('文件大小不能超过 50MB')
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
selectedFile.value = file
|
||||
uploadForm.value.metadata = {}
|
||||
uploadDialogVisible.value = true
|
||||
|
||||
const handleUploadSuccess = (response: any) => {
|
||||
if (response.jobId) {
|
||||
ElMessage.success('文档上传成功,正在处理中...')
|
||||
emit('upload-success')
|
||||
loadDocuments()
|
||||
pollJobStatus(response.jobId)
|
||||
} else {
|
||||
ElMessage.error('上传失败')
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadError = () => {
|
||||
ElMessage.error('上传失败,请重试')
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
formData.append('kb_id', props.kbId)
|
||||
formData.append('metadata', JSON.stringify(uploadForm.value.metadata))
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const response = await fetch(`${baseUrl}/admin/kb/documents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Tenant-Id': tenantStore.currentTenantId
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (result.jobId) {
|
||||
ElMessage.success('文档上传成功,正在处理中...')
|
||||
emit('upload-success')
|
||||
loadDocuments()
|
||||
pollJobStatus(result.jobId)
|
||||
} else {
|
||||
ElMessage.error(result.message || '上传失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败,请重试')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploadDialogVisible.value = false
|
||||
selectedFile.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditMetadata = (doc: DocumentWithMetadata) => {
|
||||
currentEditDoc.value = doc
|
||||
editForm.value.metadata = doc.metadata || {}
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!currentEditDoc.value) return
|
||||
|
||||
if (editMetadataFormRef.value) {
|
||||
const validation = await editMetadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const response = await fetch(`${baseUrl}/admin/kb/documents/${currentEditDoc.value.docId}/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': tenantStore.currentTenantId
|
||||
},
|
||||
body: JSON.stringify({ metadata: editForm.value.metadata })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
ElMessage.success('元数据更新成功')
|
||||
loadDocuments()
|
||||
editDialogVisible.value = false
|
||||
} else {
|
||||
const result = await response.json()
|
||||
ElMessage.error(result.message || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('更新失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pollJobStatus = async (jobId: string) => {
|
||||
|
|
@ -208,7 +408,7 @@ const pollJobStatus = async (jobId: string) => {
|
|||
setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Document) => {
|
||||
const handleDelete = async (row: DocumentWithMetadata) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该文档吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
|
|
@ -245,4 +445,27 @@ onMounted(() => {
|
|||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.metadata-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metadata-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,587 @@
|
|||
<template>
|
||||
<div class="metadata-schema-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">元数据字段配置</h1>
|
||||
<p class="page-desc">配置知识库、意图规则、话术流程、Prompt模板的动态元数据字段。[AC-IDSMETA-13]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 140px;">
|
||||
<el-option
|
||||
v-for="opt in METADATA_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="schema-card" v-loading="loading">
|
||||
<el-table :data="fields" stripe style="width: 100%">
|
||||
<el-table-column prop="field_key" label="字段标识" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<code class="field-key">{{ row.field_key }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="label" label="显示名称" width="120" />
|
||||
<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="scope" label="适用范围" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="scope-tags">
|
||||
<el-tag
|
||||
v-for="s in row.scope"
|
||||
:key="s"
|
||||
size="small"
|
||||
class="scope-tag"
|
||||
>
|
||||
{{ getScopeLabel(s) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="STATUS_TAG_MAP[row.status as MetadataFieldStatus]" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</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-dropdown trigger="click" @command="(cmd: MetadataFieldStatus) => handleStatusChange(row, cmd)">
|
||||
<el-button type="warning" link size="small">
|
||||
<el-icon><Switch /></el-icon>
|
||||
状态
|
||||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="opt in METADATA_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:command="opt.value"
|
||||
:disabled="opt.value === row.status"
|
||||
>
|
||||
<el-tag :type="STATUS_TAG_MAP[opt.value as MetadataFieldStatus]" size="small">{{ opt.label }}</el-tag>
|
||||
<span class="status-desc">{{ opt.description }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<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 && fields.length === 0" description="暂无元数据字段" />
|
||||
</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="field_key">
|
||||
<el-input
|
||||
v-model="formData.field_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="label">
|
||||
<el-input v-model="formData.label" placeholder="如:年级" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="字段类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%;" @change="onTypeChange">
|
||||
<el-option
|
||||
v-for="opt in METADATA_TYPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<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 METADATA_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
>
|
||||
<div class="status-option">
|
||||
<el-tag :type="STATUS_TAG_MAP[opt.value as MetadataFieldStatus]" size="small">{{ opt.label }}</el-tag>
|
||||
<span class="status-option-desc">{{ opt.description }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="适用范围" prop="scope">
|
||||
<el-checkbox-group v-model="formData.scope">
|
||||
<el-checkbox
|
||||
v-for="opt in METADATA_SCOPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="是否必填">
|
||||
<el-switch v-model="formData.required" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="可过滤">
|
||||
<el-switch v-model="formData.is_filterable" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="排序特征">
|
||||
<el-switch v-model="formData.is_rank_feature" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="默认值">
|
||||
<el-input v-model="formData.default_value" placeholder="可选默认值" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.type === 'enum' || formData.type === 'array_enum'"
|
||||
label="选项列表"
|
||||
prop="options"
|
||||
>
|
||||
<div class="options-container">
|
||||
<el-tag
|
||||
v-for="(opt, idx) in formData.options"
|
||||
:key="idx"
|
||||
closable
|
||||
@close="removeOption(idx)"
|
||||
class="option-tag"
|
||||
>
|
||||
{{ opt }}
|
||||
</el-tag>
|
||||
<el-input
|
||||
v-model="newOption"
|
||||
placeholder="输入后回车添加"
|
||||
size="small"
|
||||
class="option-input"
|
||||
@keyup.enter="addOption"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left" v-if="isEdit && formData.status === 'deprecated'">
|
||||
<el-tag type="danger">废弃影响范围</el-tag>
|
||||
</el-divider>
|
||||
<div v-if="isEdit && formData.status === 'deprecated'" class="deprecated-impact">
|
||||
<el-alert type="warning" :closable="false" show-icon>
|
||||
<template #title>
|
||||
将此字段设为废弃后,以下影响将生效 [AC-IDSMETA-14]
|
||||
</template>
|
||||
</el-alert>
|
||||
<ul class="impact-list">
|
||||
<li>新建对象时,此字段将不再显示</li>
|
||||
<li>已有对象的历史数据仍可查看,但不可编辑</li>
|
||||
<li>作为过滤条件时,仅对历史数据生效</li>
|
||||
</ul>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Switch, ArrowDown } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { metadataSchemaApi } from '@/api/metadata-schema'
|
||||
import {
|
||||
METADATA_STATUS_OPTIONS,
|
||||
METADATA_SCOPE_OPTIONS,
|
||||
METADATA_TYPE_OPTIONS,
|
||||
STATUS_TAG_MAP,
|
||||
type MetadataFieldDefinition,
|
||||
type MetadataFieldCreateRequest,
|
||||
type MetadataFieldUpdateRequest,
|
||||
type MetadataFieldStatus,
|
||||
type MetadataScope
|
||||
} from '@/types/metadata'
|
||||
|
||||
const loading = ref(false)
|
||||
const fields = ref<MetadataFieldDefinition[]>([])
|
||||
const filterStatus = ref<MetadataFieldStatus | ''>('')
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const newOption = ref('')
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
field_key: '',
|
||||
label: '',
|
||||
type: 'string' as 'string' | 'number' | 'boolean' | 'enum' | 'array_enum',
|
||||
required: false,
|
||||
options: [] as string[],
|
||||
default_value: '' as string | number | boolean,
|
||||
scope: [] as MetadataScope[],
|
||||
is_filterable: true,
|
||||
is_rank_feature: false,
|
||||
status: 'draft' as MetadataFieldStatus
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
field_key: [
|
||||
{ required: true, message: '请输入字段标识', trigger: 'blur' },
|
||||
{ pattern: /^[a-z0-9_]+$/, message: '仅允许小写字母、数字、下划线', trigger: 'blur' }
|
||||
],
|
||||
label: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
scope: [{ type: 'array', min: 1, message: '请至少选择一个适用范围', trigger: 'change' }],
|
||||
options: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if ((formData.type === 'enum' || formData.type === 'array_enum') && (!value || value.length === 0)) {
|
||||
callback(new Error('枚举类型必须至少有一个选项'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
return METADATA_TYPE_OPTIONS.find(o => o.value === type)?.label || type
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: MetadataFieldStatus) => {
|
||||
return METADATA_STATUS_OPTIONS.find(o => o.value === status)?.label || status
|
||||
}
|
||||
|
||||
const getScopeLabel = (scope: MetadataScope) => {
|
||||
return METADATA_SCOPE_OPTIONS.find(o => o.value === scope)?.label || scope
|
||||
}
|
||||
|
||||
const fetchFields = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await metadataSchemaApi.list(filterStatus.value || undefined)
|
||||
fields.value = res.items || []
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '获取元数据字段失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
Object.assign(formData, {
|
||||
id: '',
|
||||
field_key: '',
|
||||
label: '',
|
||||
type: 'string',
|
||||
required: false,
|
||||
options: [],
|
||||
default_value: '',
|
||||
scope: [],
|
||||
is_filterable: true,
|
||||
is_rank_feature: false,
|
||||
status: 'draft'
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (field: MetadataFieldDefinition) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: field.id,
|
||||
field_key: field.field_key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options || [],
|
||||
default_value: field.default ?? '',
|
||||
scope: [...field.scope],
|
||||
is_filterable: field.is_filterable,
|
||||
is_rank_feature: field.is_rank_feature,
|
||||
status: field.status
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (field: MetadataFieldDefinition) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除字段「${field.label}(${field.field_key})」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await metadataSchemaApi.delete(field.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchFields()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (field: MetadataFieldDefinition, newStatus: MetadataFieldStatus) => {
|
||||
if (newStatus === field.status) return
|
||||
|
||||
const statusDesc = METADATA_STATUS_OPTIONS.find(o => o.value === newStatus)?.description
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要将字段「${field.label}」状态改为「${getStatusLabel(newStatus)}」吗?\n\n${statusDesc}`,
|
||||
'状态变更确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await metadataSchemaApi.update(field.id, { status: newStatus })
|
||||
ElMessage.success('状态更新成功')
|
||||
fetchFields()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.message || '状态更新失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onTypeChange = () => {
|
||||
if (formData.type !== 'enum' && formData.type !== 'array_enum') {
|
||||
formData.options = []
|
||||
}
|
||||
}
|
||||
|
||||
const addOption = () => {
|
||||
const value = newOption.value.trim()
|
||||
if (value && !formData.options.includes(value)) {
|
||||
formData.options.push(value)
|
||||
newOption.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
formData.options.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: MetadataFieldCreateRequest | MetadataFieldUpdateRequest = {
|
||||
field_key: formData.field_key,
|
||||
label: formData.label,
|
||||
type: formData.type,
|
||||
required: formData.required,
|
||||
scope: formData.scope,
|
||||
is_filterable: formData.is_filterable,
|
||||
is_rank_feature: formData.is_rank_feature,
|
||||
status: formData.status
|
||||
}
|
||||
|
||||
if (formData.type === 'enum' || formData.type === 'array_enum') {
|
||||
data.options = formData.options
|
||||
}
|
||||
|
||||
if (formData.default_value !== '') {
|
||||
data.default = formData.default_value
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await metadataSchemaApi.update(formData.id, data as MetadataFieldUpdateRequest)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await metadataSchemaApi.create(data as MetadataFieldCreateRequest)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchFields()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(filterStatus, () => {
|
||||
fetchFields()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchFields()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.metadata-schema-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;
|
||||
}
|
||||
|
||||
.schema-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.field-key {
|
||||
padding: 2px 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.scope-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.status-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-option-desc {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.option-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option-input {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.deprecated-impact {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.impact-list {
|
||||
margin: 12px 0 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -248,7 +248,6 @@ import {
|
|||
type ConversationDetail,
|
||||
type ExportTaskResponse
|
||||
} from '@/api/monitoring'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<ConversationItem[]>([])
|
||||
|
|
@ -433,7 +432,8 @@ const downloadExport = () => {
|
|||
if (exportTask.value) {
|
||||
const url = getExportDownloadUrl(exportTask.value.taskId)
|
||||
const link = document.createElement('a')
|
||||
link.href = request.defaults.baseURL + url
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_API || '/api'
|
||||
link.href = baseURL + url
|
||||
link.download = exportTask.value.fileName || 'export.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">Prompt 模板管理</h1>
|
||||
<p class="page-desc">管理不同场景的 Prompt 模板,支持版本管理和一键回滚。</p>
|
||||
<p class="page-desc">管理不同场景的 Prompt 模板,支持版本管理和一键回滚。[AC-IDSMETA-16]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 150px;">
|
||||
|
|
@ -43,6 +43,14 @@
|
|||
<span v-else class="no-version">未发布</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="元数据" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
|
||||
{{ Object.keys(row.metadata).length }} 个字段
|
||||
</el-tag>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updated_at) }}
|
||||
|
|
@ -78,7 +86,7 @@
|
|||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑模板' : '新建模板'"
|
||||
width="900px"
|
||||
width="950px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
|
|
@ -100,6 +108,19 @@
|
|||
<el-form-item label="描述">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="prompt_template"
|
||||
v-model="formData.metadata"
|
||||
:is-new-object="!isEdit"
|
||||
:col-span="8"
|
||||
/>
|
||||
|
||||
<el-divider content-position="left">系统指令</el-divider>
|
||||
|
||||
<el-form-item label="系统指令" prop="system_instruction">
|
||||
<div class="content-editor">
|
||||
<div class="editor-main">
|
||||
|
|
@ -183,6 +204,7 @@ import {
|
|||
publishPromptTemplate,
|
||||
rollbackPromptTemplate
|
||||
} from '@/api/prompt-template'
|
||||
import { MetadataForm } from '@/components/metadata'
|
||||
import { SCENE_OPTIONS, BUILTIN_VARIABLES } from '@/types/prompt-template'
|
||||
import type { PromptTemplate, PromptTemplateDetail, PromptTemplateCreate, PromptTemplateUpdate, PromptVariable } from '@/types/prompt-template'
|
||||
import TemplateDetail from './components/TemplateDetail.vue'
|
||||
|
|
@ -197,6 +219,7 @@ const detailDrawer = ref(false)
|
|||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const currentTemplate = ref<PromptTemplateDetail | null>(null)
|
||||
const currentEditId = ref('')
|
||||
const previewDialogVisible = ref(false)
|
||||
|
|
@ -208,7 +231,8 @@ const formData = ref<PromptTemplateCreate>({
|
|||
scene: '',
|
||||
description: '',
|
||||
system_instruction: '',
|
||||
variables: []
|
||||
variables: [],
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
|
|
@ -270,7 +294,8 @@ const handleCreate = () => {
|
|||
scene: '',
|
||||
description: '',
|
||||
system_instruction: '',
|
||||
variables: []
|
||||
variables: [],
|
||||
metadata: {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
|
@ -285,7 +310,8 @@ const handleEdit = async (row: PromptTemplate) => {
|
|||
scene: detail.scene,
|
||||
description: detail.description || '',
|
||||
system_instruction: detail.current_content || '',
|
||||
variables: detail.variables || []
|
||||
variables: detail.variables || [],
|
||||
metadata: detail.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
|
|
@ -358,6 +384,14 @@ const handleSubmit = async () => {
|
|||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
|
|
@ -366,7 +400,8 @@ const handleSubmit = async () => {
|
|||
scene: formData.value.scene,
|
||||
description: formData.value.description,
|
||||
system_instruction: formData.value.system_instruction,
|
||||
variables: formData.value.variables
|
||||
variables: formData.value.variables,
|
||||
metadata: formData.value.metadata
|
||||
}
|
||||
await updatePromptTemplate(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
|
|
@ -509,6 +544,10 @@ onMounted(() => {
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.content-editor {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<div class="constraint-manager">
|
||||
<div class="constraint-tags" v-if="modelValue && modelValue.length > 0">
|
||||
<el-tag
|
||||
v-for="(constraint, index) in modelValue"
|
||||
:key="index"
|
||||
closable
|
||||
type="info"
|
||||
@close="removeConstraint(index)"
|
||||
class="constraint-tag"
|
||||
:title="constraint"
|
||||
>
|
||||
<span class="constraint-text">{{ constraint }}</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="newConstraint"
|
||||
placeholder="输入约束条件后按回车添加"
|
||||
@keyup.enter="addConstraint"
|
||||
class="constraint-input"
|
||||
size="small"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="addConstraint" :disabled="!newConstraint.trim()">
|
||||
添加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<div class="constraint-presets">
|
||||
<span class="preset-label">常用约束:</span>
|
||||
<el-button
|
||||
v-for="preset in PRESET_CONSTRAINTS"
|
||||
:key="preset"
|
||||
size="small"
|
||||
round
|
||||
@click="addPreset(preset)"
|
||||
:disabled="modelValue?.includes(preset)"
|
||||
>
|
||||
{{ preset }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { PRESET_CONSTRAINTS } from '@/types/script-flow'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
|
||||
const newConstraint = ref('')
|
||||
|
||||
const addConstraint = () => {
|
||||
const value = newConstraint.value.trim()
|
||||
if (!value) return
|
||||
|
||||
const currentConstraints = props.modelValue || []
|
||||
if (currentConstraints.includes(value)) {
|
||||
newConstraint.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...currentConstraints, value])
|
||||
newConstraint.value = ''
|
||||
}
|
||||
|
||||
const removeConstraint = (index: number) => {
|
||||
const currentConstraints = props.modelValue || []
|
||||
const newConstraints = [...currentConstraints]
|
||||
newConstraints.splice(index, 1)
|
||||
emit('update:modelValue', newConstraints)
|
||||
}
|
||||
|
||||
const addPreset = (preset: string) => {
|
||||
const currentConstraints = props.modelValue || []
|
||||
if (currentConstraints.includes(preset)) return
|
||||
emit('update:modelValue', [...currentConstraints, preset])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.constraint-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.constraint-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.constraint-tag {
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.constraint-tag :deep(.el-tag__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.constraint-tag :deep(.el-tag__close) {
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.constraint-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.constraint-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.constraint-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">话术流程管理</h1>
|
||||
<p class="page-desc">编排多步骤的话术流程,引导用户按固定步骤完成信息收集。</p>
|
||||
<p class="page-desc">编排多步骤的话术流程,引导用户按固定步骤完成信息收集。[AC-IDSMETA-16]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
|
|
@ -25,6 +25,14 @@
|
|||
</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 label="元数据" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
|
||||
{{ Object.keys(row.metadata).length }} 个字段
|
||||
</el-tag>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
|
|
@ -65,7 +73,7 @@
|
|||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑流程' : '新建流程'"
|
||||
width="900px"
|
||||
width="950px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
|
|
@ -86,6 +94,16 @@
|
|||
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述(可选)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="script_flow"
|
||||
v-model="formData.metadata"
|
||||
:is-new-object="!isEdit"
|
||||
:col-span="8"
|
||||
/>
|
||||
|
||||
<el-divider content-position="left">步骤配置</el-divider>
|
||||
|
||||
<div class="steps-editor">
|
||||
|
|
@ -107,14 +125,107 @@
|
|||
</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 label="话术模式">
|
||||
<el-radio-group v-model="element.script_mode" size="small">
|
||||
<el-radio-button
|
||||
v-for="opt in SCRIPT_MODE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
<el-tooltip :content="opt.description" placement="top">
|
||||
<span>{{ opt.label }} <el-icon class="mode-help-icon"><QuestionFilled /></el-icon></span>
|
||||
</el-tooltip>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="element.script_mode === 'fixed'">
|
||||
<el-form-item label="话术内容" required>
|
||||
<el-input
|
||||
v-model="element.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入固定话术内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="element.script_mode === 'flexible'">
|
||||
<el-form-item label="步骤意图" required>
|
||||
<el-input
|
||||
v-model="element.intent"
|
||||
placeholder="例如:获取用户姓名"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="意图说明">
|
||||
<el-input
|
||||
v-model="element.intent_description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="详细描述这一步的目的和期望效果"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="话术约束">
|
||||
<ConstraintManager v-model="element.script_constraints" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Fallback话术" required>
|
||||
<el-input
|
||||
v-model="element.content"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="AI生成失败时使用的备用话术"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="期望变量">
|
||||
<el-select
|
||||
v-model="element.expected_variables"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入变量名后回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="element.script_mode === 'template'">
|
||||
<el-form-item label="话术模板" required>
|
||||
<el-input
|
||||
v-model="element.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="使用 {变量名} 标记可变部分,例如:您好{user_name},请问您{inquiry_style}?"
|
||||
/>
|
||||
<div class="template-hint">
|
||||
提示:使用 {变量名} 标记需要AI填充的部分
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="步骤意图">
|
||||
<el-input
|
||||
v-model="element.intent"
|
||||
placeholder="可选:描述模板的使用场景"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="期望变量">
|
||||
<el-select
|
||||
v-model="element.expected_variables"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入变量名后回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="等待输入">
|
||||
|
|
@ -139,6 +250,78 @@
|
|||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left" v-if="element.wait_input">分支跳转</el-divider>
|
||||
|
||||
<div v-if="element.wait_input" class="branch-editor">
|
||||
<div
|
||||
v-for="(cond, ci) in (element.next_conditions || [])"
|
||||
:key="ci"
|
||||
class="branch-item"
|
||||
>
|
||||
<el-row :gutter="8" align="middle">
|
||||
<el-col :span="10">
|
||||
<el-form-item label="关键词" label-width="60px" style="margin-bottom: 0;">
|
||||
<el-select
|
||||
v-model="cond.keywords"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入关键词回车"
|
||||
style="width: 100%"
|
||||
size="small"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="正则" label-width="40px" style="margin-bottom: 0;">
|
||||
<el-input
|
||||
v-model="cond.pattern"
|
||||
placeholder="可选"
|
||||
size="small"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-form-item label="跳转" label-width="40px" style="margin-bottom: 0;">
|
||||
<el-select v-model="cond.goto_step" placeholder="步骤" size="small" style="width: 100%">
|
||||
<el-option
|
||||
v-for="(s, si) in formData.steps"
|
||||
:key="si"
|
||||
:label="'步骤 ' + (si + 1)"
|
||||
:value="si + 1"
|
||||
:disabled="si === index"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button type="danger" link size="small" @click="removeBranch(element, ci)">删除</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-row :gutter="8" align="middle" style="margin-top: 8px;">
|
||||
<el-col :span="16">
|
||||
<el-button type="primary" link size="small" @click="addBranch(element)">
|
||||
<el-icon><Plus /></el-icon> 添加分支条件
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="默认跳转" label-width="70px" style="margin-bottom: 0;">
|
||||
<el-select v-model="element.default_next" placeholder="顺序" size="small" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="(s, si) in formData.steps"
|
||||
:key="si"
|
||||
:label="'步骤 ' + (si + 1)"
|
||||
:value="si + 1"
|
||||
:disabled="si === index"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -172,7 +355,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, View, Rank, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { Plus, Edit, Delete, View, Rank, VideoPlay, QuestionFilled } from '@element-plus/icons-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import {
|
||||
listScriptFlows,
|
||||
|
|
@ -181,10 +364,12 @@ import {
|
|||
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 { 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 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[]>([])
|
||||
|
|
@ -196,6 +381,7 @@ const currentSimulateFlowName = ref('')
|
|||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const currentFlow = ref<ScriptFlowDetail | null>(null)
|
||||
const currentEditId = ref('')
|
||||
|
||||
|
|
@ -205,7 +391,8 @@ const defaultFormData = (): ScriptFlowCreate => ({
|
|||
name: '',
|
||||
description: '',
|
||||
steps: [],
|
||||
is_enabled: true
|
||||
is_enabled: true,
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formData = ref<ScriptFlowCreate>(defaultFormData())
|
||||
|
|
@ -253,8 +440,14 @@ const handleEdit = async (row: ScriptFlow) => {
|
|||
formData.value = {
|
||||
name: detail.name,
|
||||
description: detail.description || '',
|
||||
steps: detail.steps || [],
|
||||
is_enabled: detail.is_enabled
|
||||
steps: (detail.steps || []).map(step => ({
|
||||
...step,
|
||||
script_mode: step.script_mode || 'fixed',
|
||||
script_constraints: step.script_constraints || [],
|
||||
expected_variables: step.expected_variables || []
|
||||
})),
|
||||
is_enabled: detail.is_enabled,
|
||||
metadata: detail.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
|
|
@ -312,17 +505,47 @@ const addStep = () => {
|
|||
wait_input: true,
|
||||
timeout_seconds: 30,
|
||||
timeout_action: 'repeat',
|
||||
next_conditions: []
|
||||
next_conditions: [],
|
||||
script_mode: 'fixed',
|
||||
script_constraints: [],
|
||||
expected_variables: []
|
||||
})
|
||||
}
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
const removedStepNo = index + 1
|
||||
formData.value.steps.splice(index, 1)
|
||||
formData.value.steps.forEach((step, i) => {
|
||||
step.step_no = i + 1
|
||||
if (step.next_conditions) {
|
||||
step.next_conditions = step.next_conditions
|
||||
.filter(c => c.goto_step !== removedStepNo)
|
||||
.map(c => ({
|
||||
...c,
|
||||
goto_step: c.goto_step > removedStepNo ? c.goto_step - 1 : c.goto_step
|
||||
}))
|
||||
}
|
||||
if (step.default_next !== undefined && step.default_next !== null) {
|
||||
if (step.default_next === removedStepNo) {
|
||||
step.default_next = undefined
|
||||
} else if (step.default_next > removedStepNo) {
|
||||
step.default_next = step.default_next - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addBranch = (step: FlowStep) => {
|
||||
if (!step.next_conditions) {
|
||||
step.next_conditions = []
|
||||
}
|
||||
step.next_conditions.push({ keywords: [], goto_step: 0 })
|
||||
}
|
||||
|
||||
const removeBranch = (step: FlowStep, index: number) => {
|
||||
step.next_conditions?.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
|
@ -330,11 +553,45 @@ const handleSubmit = async () => {
|
|||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.value.steps.length === 0) {
|
||||
ElMessage.warning('请至少添加一个步骤')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < formData.value.steps.length; i++) {
|
||||
const step = formData.value.steps[i]
|
||||
const stepLabel = `步骤 ${i + 1}`
|
||||
|
||||
if (step.script_mode === 'fixed' && !step.content?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:固定模式需要填写话术内容`)
|
||||
return
|
||||
}
|
||||
|
||||
if (step.script_mode === 'flexible') {
|
||||
if (!step.intent?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:灵活模式需要填写步骤意图`)
|
||||
return
|
||||
}
|
||||
if (!step.content?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:灵活模式需要填写Fallback话术`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (step.script_mode === 'template' && !step.content?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:模板模式需要填写话术模板`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const submitData = {
|
||||
|
|
@ -412,6 +669,10 @@ onMounted(() => {
|
|||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.steps-editor {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
|
@ -450,4 +711,32 @@ onMounted(() => {
|
|||
.step-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.template-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.mode-help-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.branch-editor {
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.branch-item {
|
||||
padding: 8px;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ Admin API routes for AI Service management.
|
|||
|
||||
from app.api.admin.api_key import router as api_key_router
|
||||
from app.api.admin.dashboard import router as dashboard_router
|
||||
from app.api.admin.decomposition_template import router as decomposition_template_router
|
||||
from app.api.admin.embedding import router as embedding_router
|
||||
from app.api.admin.flow_test import router as flow_test_router
|
||||
from app.api.admin.guardrails import router as guardrails_router
|
||||
from app.api.admin.intent_rules import router as intent_rules_router
|
||||
from app.api.admin.kb import router as kb_router
|
||||
from app.api.admin.llm import router as llm_router
|
||||
from app.api.admin.metadata_field_definition import router as metadata_field_definition_router
|
||||
from app.api.admin.metadata_schema import router as metadata_schema_router
|
||||
from app.api.admin.monitoring import router as monitoring_router
|
||||
from app.api.admin.prompt_templates import router as prompt_templates_router
|
||||
from app.api.admin.rag import router as rag_router
|
||||
|
|
@ -21,12 +24,15 @@ from app.api.admin.tenants import router as tenants_router
|
|||
__all__ = [
|
||||
"api_key_router",
|
||||
"dashboard_router",
|
||||
"decomposition_template_router",
|
||||
"embedding_router",
|
||||
"flow_test_router",
|
||||
"guardrails_router",
|
||||
"intent_rules_router",
|
||||
"kb_router",
|
||||
"llm_router",
|
||||
"metadata_field_definition_router",
|
||||
"metadata_schema_router",
|
||||
"monitoring_router",
|
||||
"prompt_templates_router",
|
||||
"rag_router",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,360 @@
|
|||
"""
|
||||
Metadata Field Definition API.
|
||||
[AC-IDSMETA-13, AC-IDSMETA-14] 元数据字段定义管理接口,支持字段级状态治理。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models.entities import (
|
||||
MetadataFieldDefinition,
|
||||
MetadataFieldDefinitionCreate,
|
||||
MetadataFieldDefinitionUpdate,
|
||||
MetadataFieldStatus,
|
||||
)
|
||||
from app.services.metadata_field_definition_service import MetadataFieldDefinitionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/metadata-schemas", tags=["MetadataSchemas"])
|
||||
|
||||
|
||||
def get_current_tenant_id() -> str:
|
||||
"""Get current tenant ID from context."""
|
||||
tenant_id = get_tenant_id()
|
||||
if not tenant_id:
|
||||
raise MissingTenantIdException()
|
||||
return tenant_id
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listMetadataSchemas",
|
||||
summary="List metadata schemas",
|
||||
description="[AC-IDSMETA-13] 获取元数据字段定义列表,支持按状态过滤",
|
||||
)
|
||||
async def list_schemas(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
status: Annotated[str | None, Query(
|
||||
description="按状态过滤: draft/active/deprecated"
|
||||
)] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-13] 列出元数据字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-13] Listing metadata field definitions: "
|
||||
f"tenant={tenant_id}, status={status}"
|
||||
)
|
||||
|
||||
if status and status not in [s.value for s in MetadataFieldStatus]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_STATUS",
|
||||
"message": f"Invalid status: {status}",
|
||||
"details": {
|
||||
"valid_values": [s.value for s in MetadataFieldStatus]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
fields = await service.list_field_definitions(tenant_id, status)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"field_key": f.field_key,
|
||||
"label": f.label,
|
||||
"type": f.type,
|
||||
"required": f.required,
|
||||
"options": f.options,
|
||||
"default": f.default_value,
|
||||
"scope": f.scope,
|
||||
"is_filterable": f.is_filterable,
|
||||
"is_rank_feature": f.is_rank_feature,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createMetadataSchema",
|
||||
summary="Create metadata schema",
|
||||
description="[AC-IDSMETA-13] 创建新的元数据字段定义",
|
||||
status_code=201,
|
||||
)
|
||||
async def create_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
field_create: MetadataFieldDefinitionCreate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-13] 创建元数据字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-13] Creating metadata field definition: "
|
||||
f"tenant={tenant_id}, field_key={field_create.field_key}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
|
||||
try:
|
||||
field = await service.create_field_definition(tenant_id, field_create)
|
||||
await session.commit()
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"id": str(field.id),
|
||||
"field_key": field.field_key,
|
||||
"label": field.label,
|
||||
"type": field.type,
|
||||
"required": field.required,
|
||||
"options": field.options,
|
||||
"default": field.default_value,
|
||||
"scope": field.scope,
|
||||
"is_filterable": field.is_filterable,
|
||||
"is_rank_feature": field.is_rank_feature,
|
||||
"status": field.status,
|
||||
"created_at": field.created_at.isoformat() if field.created_at else None,
|
||||
"updated_at": field.updated_at.isoformat() if field.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{id}",
|
||||
operation_id="updateMetadataSchema",
|
||||
summary="Update metadata schema",
|
||||
description="[AC-IDSMETA-14] 更新元数据字段定义,支持状态切换",
|
||||
)
|
||||
async def update_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
id: str,
|
||||
field_update: MetadataFieldDefinitionUpdate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14] 更新元数据字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Updating metadata field definition: "
|
||||
f"tenant={tenant_id}, id={id}"
|
||||
)
|
||||
|
||||
if field_update.status and field_update.status not in [s.value for s in MetadataFieldStatus]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_STATUS",
|
||||
"message": f"Invalid status: {field_update.status}",
|
||||
"details": {
|
||||
"valid_values": [s.value for s in MetadataFieldStatus]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
|
||||
try:
|
||||
field = await service.update_field_definition(tenant_id, id, field_update)
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
if not field:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Field definition {id} not found",
|
||||
}
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"id": str(field.id),
|
||||
"field_key": field.field_key,
|
||||
"label": field.label,
|
||||
"type": field.type,
|
||||
"required": field.required,
|
||||
"options": field.options,
|
||||
"default": field.default_value,
|
||||
"scope": field.scope,
|
||||
"is_filterable": field.is_filterable,
|
||||
"is_rank_feature": field.is_rank_feature,
|
||||
"status": field.status,
|
||||
"created_at": field.created_at.isoformat() if field.created_at else None,
|
||||
"updated_at": field.updated_at.isoformat() if field.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/active",
|
||||
operation_id="getActiveMetadataSchemas",
|
||||
summary="Get active metadata schemas",
|
||||
description="[AC-IDSMETA-14] 获取活跃状态的字段定义,用于新建对象时选择",
|
||||
)
|
||||
async def get_active_schemas(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
scope: Annotated[str | None, Query(
|
||||
description="按适用范围过滤: kb_document/intent_rule/script_flow/prompt_template"
|
||||
)] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14] 获取活跃状态的字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Getting active metadata field definitions: "
|
||||
f"tenant={tenant_id}, scope={scope}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
fields = await service.get_active_field_definitions(tenant_id, scope)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"field_key": f.field_key,
|
||||
"label": f.label,
|
||||
"type": f.type,
|
||||
"required": f.required,
|
||||
"options": f.options,
|
||||
"default": f.default_value,
|
||||
"scope": f.scope,
|
||||
"is_filterable": f.is_filterable,
|
||||
"is_rank_feature": f.is_rank_feature,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/readable",
|
||||
operation_id="getReadableMetadataSchemas",
|
||||
summary="Get readable metadata schemas",
|
||||
description="[AC-IDSMETA-14] 获取可读取的字段定义(active + deprecated),用于历史数据展示",
|
||||
)
|
||||
async def get_readable_schemas(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
scope: Annotated[str | None, Query(
|
||||
description="按适用范围过滤: kb_document/intent_rule/script_flow/prompt_template"
|
||||
)] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14] 获取可读取的字段定义(active + deprecated)
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Getting readable metadata field definitions: "
|
||||
f"tenant={tenant_id}, scope={scope}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
fields = await service.get_field_definitions_for_read(tenant_id, scope)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"field_key": f.field_key,
|
||||
"label": f.label,
|
||||
"type": f.type,
|
||||
"required": f.required,
|
||||
"options": f.options,
|
||||
"default": f.default_value,
|
||||
"scope": f.scope,
|
||||
"is_filterable": f.is_filterable,
|
||||
"is_rank_feature": f.is_rank_feature,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/validate",
|
||||
operation_id="validateMetadataForCreate",
|
||||
summary="Validate metadata for create",
|
||||
description="[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象",
|
||||
)
|
||||
async def validate_metadata_for_create(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
body: dict[str, Any],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象
|
||||
|
||||
Request body:
|
||||
{
|
||||
"metadata": {"grade": "初一", "subject": "语文"},
|
||||
"scope": "kb_document"
|
||||
}
|
||||
"""
|
||||
metadata = body.get("metadata", {})
|
||||
scope = body.get("scope", "kb_document")
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Validating metadata for create: "
|
||||
f"tenant={tenant_id}, scope={scope}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
is_valid, errors = await service.validate_metadata_for_create(
|
||||
tenant_id, metadata, scope
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"isValid": is_valid,
|
||||
"errors": errors,
|
||||
}
|
||||
)
|
||||
|
|
@ -15,12 +15,15 @@ from app.api import chat_router, health_router
|
|||
from app.api.admin import (
|
||||
api_key_router,
|
||||
dashboard_router,
|
||||
decomposition_template_router,
|
||||
embedding_router,
|
||||
flow_test_router,
|
||||
guardrails_router,
|
||||
intent_rules_router,
|
||||
kb_router,
|
||||
llm_router,
|
||||
metadata_field_definition_router,
|
||||
metadata_schema_router,
|
||||
monitoring_router,
|
||||
prompt_templates_router,
|
||||
rag_router,
|
||||
|
|
@ -147,6 +150,7 @@ app.include_router(chat_router)
|
|||
|
||||
app.include_router(api_key_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(decomposition_template_router)
|
||||
app.include_router(embedding_router)
|
||||
app.include_router(flow_test_router)
|
||||
app.include_router(guardrails_router)
|
||||
|
|
@ -154,6 +158,8 @@ app.include_router(intent_rules_router)
|
|||
app.include_router(kb_router)
|
||||
app.include_router(kb_optimized_router)
|
||||
app.include_router(llm_router)
|
||||
app.include_router(metadata_field_definition_router)
|
||||
app.include_router(metadata_schema_router)
|
||||
app.include_router(monitoring_router)
|
||||
app.include_router(prompt_templates_router)
|
||||
app.include_router(rag_router)
|
||||
|
|
|
|||
|
|
@ -289,6 +289,7 @@ class PromptTemplate(SQLModel, table=True):
|
|||
"""
|
||||
[AC-AISVC-51, AC-AISVC-52] Prompt template entity with tenant isolation.
|
||||
Main table for storing template metadata.
|
||||
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
|
||||
"""
|
||||
|
||||
__tablename__ = "prompt_templates"
|
||||
|
|
@ -302,6 +303,11 @@ class PromptTemplate(SQLModel, table=True):
|
|||
scene: str = Field(..., description="Scene tag: chat/rag_qa/greeting/farewell")
|
||||
description: str | None = Field(default=None, description="Template description")
|
||||
is_default: bool = Field(default=False, description="Whether this is the default template for the scene")
|
||||
metadata_: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("metadata", JSON, nullable=True),
|
||||
description="[AC-IDSMETA-16] Structured metadata for the prompt template"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||
|
||||
|
|
@ -350,6 +356,7 @@ class PromptTemplateCreate(SQLModel):
|
|||
system_instruction: str
|
||||
variables: list[dict[str, Any]] | None = None
|
||||
is_default: bool = False
|
||||
metadata_: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PromptTemplateUpdate(SQLModel):
|
||||
|
|
@ -361,6 +368,7 @@ class PromptTemplateUpdate(SQLModel):
|
|||
system_instruction: str | None = None
|
||||
variables: list[dict[str, Any]] | None = None
|
||||
is_default: bool | None = None
|
||||
metadata_: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ResponseType(str, Enum):
|
||||
|
|
@ -375,6 +383,7 @@ class IntentRule(SQLModel, table=True):
|
|||
"""
|
||||
[AC-AISVC-65] Intent rule entity with tenant isolation.
|
||||
Supports keyword and regex matching for intent recognition.
|
||||
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
|
||||
"""
|
||||
|
||||
__tablename__ = "intent_rules"
|
||||
|
|
@ -407,6 +416,11 @@ class IntentRule(SQLModel, table=True):
|
|||
transfer_message: str | None = Field(default=None, description="Transfer message for transfer type")
|
||||
is_enabled: bool = Field(default=True, description="Whether the rule is enabled")
|
||||
hit_count: int = Field(default=0, description="Hit count for statistics")
|
||||
metadata_: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("metadata", JSON, nullable=True),
|
||||
description="[AC-IDSMETA-16] Structured metadata for the intent rule"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||
|
||||
|
|
@ -423,6 +437,7 @@ class IntentRuleCreate(SQLModel):
|
|||
flow_id: str | None = None
|
||||
fixed_reply: str | None = None
|
||||
transfer_message: str | None = None
|
||||
metadata_: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class IntentRuleUpdate(SQLModel):
|
||||
|
|
@ -438,6 +453,7 @@ class IntentRuleUpdate(SQLModel):
|
|||
fixed_reply: str | None = None
|
||||
transfer_message: str | None = None
|
||||
is_enabled: bool | None = None
|
||||
metadata_: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class IntentMatchResult:
|
||||
|
|
@ -643,6 +659,7 @@ class ScriptFlow(SQLModel, table=True):
|
|||
"""
|
||||
[AC-AISVC-71] Script flow entity with tenant isolation.
|
||||
Stores flow definition with steps in JSONB format.
|
||||
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
|
||||
"""
|
||||
|
||||
__tablename__ = "script_flows"
|
||||
|
|
@ -660,6 +677,11 @@ class ScriptFlow(SQLModel, table=True):
|
|||
description="Flow steps list with step_no, content, wait_input, timeout_seconds"
|
||||
)
|
||||
is_enabled: bool = Field(default=True, description="Whether the flow is enabled")
|
||||
metadata_: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("metadata", JSON, nullable=True),
|
||||
description="[AC-IDSMETA-16] Structured metadata for the script flow"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||
|
||||
|
|
@ -700,8 +722,18 @@ class FlowInstance(SQLModel, table=True):
|
|||
completed_at: datetime | None = Field(default=None, description="Completion time (nullable)")
|
||||
|
||||
|
||||
class ScriptMode(str, Enum):
|
||||
"""[AC-IDS-01] Script generation mode for flow steps."""
|
||||
FIXED = "fixed"
|
||||
FLEXIBLE = "flexible"
|
||||
TEMPLATE = "template"
|
||||
|
||||
|
||||
class FlowStep(SQLModel):
|
||||
"""[AC-AISVC-71] Schema for a single flow step."""
|
||||
"""
|
||||
[AC-AISVC-71] Schema for a single flow step.
|
||||
[AC-IDS-01] Extended with intent-driven script generation fields.
|
||||
"""
|
||||
|
||||
step_no: int = Field(..., ge=1, description="Step number (1-indexed)")
|
||||
content: str = Field(..., description="Script content for this step")
|
||||
|
|
@ -717,6 +749,31 @@ class FlowStep(SQLModel):
|
|||
)
|
||||
default_next: int | None = Field(default=None, description="Default next step if no condition matches")
|
||||
|
||||
script_mode: str = Field(
|
||||
default=ScriptMode.FIXED.value,
|
||||
description="[AC-IDS-01] Script mode: fixed/flexible/template"
|
||||
)
|
||||
intent: str | None = Field(
|
||||
default=None,
|
||||
description="[AC-IDS-01] Step intent for flexible mode (e.g., '获取用户姓名')"
|
||||
)
|
||||
intent_description: str | None = Field(
|
||||
default=None,
|
||||
description="[AC-IDS-01] Detailed intent description for better AI understanding"
|
||||
)
|
||||
script_constraints: list[str] | None = Field(
|
||||
default=None,
|
||||
description="[AC-IDS-01] Script constraints for flexible mode (e.g., ['必须礼貌', '语气自然'])"
|
||||
)
|
||||
expected_variables: list[str] | None = Field(
|
||||
default=None,
|
||||
description="[AC-IDS-01] Expected variables to extract from user input"
|
||||
)
|
||||
rag_config: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="RAG configuration for this step: {'enabled': true, 'tag_filter': {'grade': '${context.grade}', 'type': '痛点'}}"
|
||||
)
|
||||
|
||||
|
||||
class ScriptFlowCreate(SQLModel):
|
||||
"""[AC-AISVC-71] Schema for creating a new script flow."""
|
||||
|
|
@ -725,6 +782,7 @@ class ScriptFlowCreate(SQLModel):
|
|||
description: str | None = None
|
||||
steps: list[dict[str, Any]]
|
||||
is_enabled: bool = True
|
||||
metadata_: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ScriptFlowUpdate(SQLModel):
|
||||
|
|
@ -734,6 +792,7 @@ class ScriptFlowUpdate(SQLModel):
|
|||
description: str | None = None
|
||||
steps: list[dict[str, Any]] | None = None
|
||||
is_enabled: bool | None = None
|
||||
metadata_: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FlowAdvanceResult:
|
||||
|
|
@ -886,3 +945,271 @@ class ConversationDetail(SQLModel):
|
|||
should_transfer: bool = False
|
||||
execution_steps: list[dict[str, Any]] | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MetadataFieldType(str, Enum):
|
||||
"""元数据字段类型"""
|
||||
STRING = "string"
|
||||
NUMBER = "number"
|
||||
BOOLEAN = "boolean"
|
||||
ENUM = "enum"
|
||||
ARRAY_ENUM = "array_enum"
|
||||
|
||||
|
||||
class MetadataFieldStatus(str, Enum):
|
||||
"""[AC-IDSMETA-13] 元数据字段状态"""
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
DEPRECATED = "deprecated"
|
||||
|
||||
|
||||
class MetadataScope(str, Enum):
|
||||
"""[AC-IDSMETA-15] 元数据字段适用范围"""
|
||||
KB_DOCUMENT = "kb_document"
|
||||
INTENT_RULE = "intent_rule"
|
||||
SCRIPT_FLOW = "script_flow"
|
||||
PROMPT_TEMPLATE = "prompt_template"
|
||||
|
||||
|
||||
class MetadataField(SQLModel):
|
||||
"""元数据字段定义(非持久化,用于嵌套结构)"""
|
||||
name: str = Field(..., description="字段名称,如 grade, subject, industry")
|
||||
label: str = Field(..., description="字段显示名称,如 年级, 学科, 行业")
|
||||
field_type: str = Field(
|
||||
default=MetadataFieldType.STRING.value,
|
||||
description="字段类型: string/number/boolean/enum/array_enum"
|
||||
)
|
||||
options: list[str] | None = Field(
|
||||
default=None,
|
||||
description="选项列表,用于 enum/array_enum 类型,如 ['初一', '初二', '初三']"
|
||||
)
|
||||
required: bool = Field(default=False, description="是否必填")
|
||||
default_value: str | None = Field(default=None, description="默认值")
|
||||
description: str | None = Field(default=None, description="字段描述")
|
||||
sort_order: int = Field(default=0, description="排序顺序")
|
||||
|
||||
|
||||
class MetadataFieldDefinition(SQLModel, table=True):
|
||||
"""
|
||||
[AC-IDSMETA-13] 元数据字段定义表
|
||||
每个字段独立存储,支持字段级状态管理(draft/active/deprecated)
|
||||
"""
|
||||
|
||||
__tablename__ = "metadata_field_definitions"
|
||||
__table_args__ = (
|
||||
Index("ix_metadata_field_definitions_tenant", "tenant_id"),
|
||||
Index("ix_metadata_field_definitions_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_metadata_field_definitions_tenant_field_key", "tenant_id", "field_key", unique=True),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
|
||||
field_key: str = Field(
|
||||
...,
|
||||
description="字段键名,仅允许小写字母数字下划线,如 grade, subject, industry",
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
)
|
||||
label: str = Field(..., description="字段显示名称", min_length=1, max_length=64)
|
||||
type: str = Field(
|
||||
default=MetadataFieldType.STRING.value,
|
||||
description="字段类型: string/number/boolean/enum/array_enum"
|
||||
)
|
||||
required: bool = Field(default=False, description="是否必填")
|
||||
options: list[str] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("options", JSON, nullable=True),
|
||||
description="选项列表,用于 enum/array_enum 类型"
|
||||
)
|
||||
default_value: str | None = Field(default=None, description="默认值", sa_column=Column("default_value", JSON, nullable=True))
|
||||
scope: list[str] = Field(
|
||||
default_factory=lambda: [MetadataScope.KB_DOCUMENT.value],
|
||||
sa_column=Column("scope", JSON, nullable=False),
|
||||
description="适用范围: kb_document/intent_rule/script_flow/prompt_template"
|
||||
)
|
||||
is_filterable: bool = Field(default=True, description="是否可用于过滤")
|
||||
is_rank_feature: bool = Field(default=False, description="是否用于排序特征")
|
||||
status: str = Field(
|
||||
default=MetadataFieldStatus.DRAFT.value,
|
||||
description="字段状态: draft/active/deprecated"
|
||||
)
|
||||
version: int = Field(default=1, description="版本号")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
|
||||
|
||||
|
||||
class MetadataFieldDefinitionCreate(SQLModel):
|
||||
"""[AC-IDSMETA-13] 创建元数据字段定义"""
|
||||
|
||||
field_key: str = Field(..., min_length=1, max_length=64)
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
type: str = Field(default=MetadataFieldType.STRING.value)
|
||||
required: bool = Field(default=False)
|
||||
options: list[str] | None = None
|
||||
default_value: str | int | float | bool | None = None
|
||||
scope: list[str] = Field(default_factory=lambda: [MetadataScope.KB_DOCUMENT.value])
|
||||
is_filterable: bool = Field(default=True)
|
||||
is_rank_feature: bool = Field(default=False)
|
||||
status: str = Field(default=MetadataFieldStatus.DRAFT.value)
|
||||
|
||||
|
||||
class MetadataFieldDefinitionUpdate(SQLModel):
|
||||
"""[AC-IDSMETA-14] 更新元数据字段定义"""
|
||||
|
||||
label: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
required: bool | None = None
|
||||
options: list[str] | None = None
|
||||
default_value: str | int | float | bool | None = None
|
||||
scope: list[str] | None = None
|
||||
is_filterable: bool | None = None
|
||||
is_rank_feature: bool | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class MetadataSchema(SQLModel, table=True):
|
||||
"""
|
||||
元数据模式定义(保留兼容性)
|
||||
每个租户可以定义自己的元数据字段配置
|
||||
"""
|
||||
|
||||
__tablename__ = "metadata_schemas"
|
||||
__table_args__ = (
|
||||
Index("ix_metadata_schemas_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
|
||||
name: str = Field(..., description="模式名称,如 教育行业元数据")
|
||||
description: str | None = Field(default=None, description="模式描述")
|
||||
fields: list[dict[str, Any]] = Field(
|
||||
default=[],
|
||||
sa_column=Column("fields", JSON, nullable=False),
|
||||
description="字段定义列表"
|
||||
)
|
||||
is_default: bool = Field(default=False, description="是否为租户默认模式")
|
||||
is_enabled: bool = Field(default=True, description="是否启用")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
|
||||
|
||||
|
||||
class MetadataSchemaCreate(SQLModel):
|
||||
"""创建元数据模式"""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
fields: list[dict[str, Any]]
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class MetadataSchemaUpdate(SQLModel):
|
||||
"""更新元数据模式"""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
fields: list[dict[str, Any]] | None = None
|
||||
is_default: bool | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class MetadataFieldCreate(SQLModel):
|
||||
"""创建元数据字段"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
field_type: str = "string"
|
||||
options: list[str] | None = None
|
||||
required: bool = False
|
||||
default_value: str | None = None
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class DecompositionTemplateStatus(str, Enum):
|
||||
"""[AC-IDSMETA-22] 拆解模板状态"""
|
||||
DRAFT = "draft"
|
||||
PUBLISHED = "published"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class DecompositionTemplate(SQLModel, table=True):
|
||||
"""
|
||||
[AC-IDSMETA-22] 拆解模板表
|
||||
用于将待录入文本按固定模板拆解为结构化数据
|
||||
"""
|
||||
|
||||
__tablename__ = "decomposition_templates"
|
||||
__table_args__ = (
|
||||
Index("ix_decomposition_templates_tenant", "tenant_id"),
|
||||
Index("ix_decomposition_templates_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
|
||||
name: str = Field(..., description="模板名称")
|
||||
description: str | None = Field(default=None, description="模板描述")
|
||||
version: int = Field(default=1, description="版本号")
|
||||
status: str = Field(
|
||||
default=DecompositionTemplateStatus.DRAFT.value,
|
||||
description="模板状态: draft/published/archived"
|
||||
)
|
||||
template_schema: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
sa_column=Column("template_schema", JSON, nullable=False),
|
||||
description="输出模板结构定义,包含字段名、类型、描述等"
|
||||
)
|
||||
extraction_hints: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("extraction_hints", JSON, nullable=True),
|
||||
description="提取提示,用于指导 LLM 提取特定字段"
|
||||
)
|
||||
example_input: str | None = Field(default=None, description="示例输入文本")
|
||||
example_output: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("example_output", JSON, nullable=True),
|
||||
description="示例输出 JSON"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
|
||||
|
||||
|
||||
class DecompositionTemplateCreate(SQLModel):
|
||||
"""[AC-IDSMETA-22] 创建拆解模板"""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
template_schema: dict[str, Any]
|
||||
extraction_hints: dict[str, Any] | None = None
|
||||
example_input: str | None = None
|
||||
example_output: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DecompositionTemplateUpdate(SQLModel):
|
||||
"""[AC-IDSMETA-22] 更新拆解模板"""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
template_schema: dict[str, Any] | None = None
|
||||
extraction_hints: dict[str, Any] | None = None
|
||||
example_input: str | None = None
|
||||
example_output: dict[str, Any] | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class DecompositionRequest(SQLModel):
|
||||
"""[AC-IDSMETA-21] 拆解请求"""
|
||||
|
||||
text: str = Field(..., description="待拆解的文本")
|
||||
template_id: str | None = Field(default=None, description="指定模板 ID(可选)")
|
||||
hints: dict[str, Any] | None = Field(default=None, description="额外提取提示")
|
||||
|
||||
|
||||
class DecompositionResult(SQLModel):
|
||||
"""[AC-IDSMETA-21] 拆解结果"""
|
||||
|
||||
success: bool = Field(..., description="是否成功")
|
||||
data: dict[str, Any] | None = Field(default=None, description="拆解后的结构化数据")
|
||||
template_id: str | None = Field(default=None, description="使用的模板 ID")
|
||||
template_version: int | None = Field(default=None, description="使用的模板版本")
|
||||
confidence: float | None = Field(default=None, description="拆解置信度")
|
||||
error: str | None = Field(default=None, description="错误信息")
|
||||
latency_ms: int | None = Field(default=None, description="处理耗时(毫秒)")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,472 @@
|
|||
"""
|
||||
Metadata Field Definition Service.
|
||||
[AC-IDSMETA-13, AC-IDSMETA-14] 元数据字段定义管理服务,支持字段级状态治理。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col
|
||||
|
||||
from app.models.entities import (
|
||||
MetadataFieldDefinition,
|
||||
MetadataFieldDefinitionCreate,
|
||||
MetadataFieldDefinitionUpdate,
|
||||
MetadataFieldStatus,
|
||||
MetadataFieldType,
|
||||
MetadataScope,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataFieldDefinitionService:
|
||||
"""
|
||||
[AC-IDSMETA-13] 元数据字段定义服务
|
||||
管理租户的动态元数据字段配置,支持字段级状态治理
|
||||
"""
|
||||
|
||||
FIELD_KEY_PATTERN = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def list_field_definitions(
|
||||
self,
|
||||
tenant_id: str,
|
||||
status: str | None = None,
|
||||
scope: str | None = None,
|
||||
) -> list[MetadataFieldDefinition]:
|
||||
"""
|
||||
[AC-IDSMETA-13] 列出租户所有元数据字段定义
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
status: 按状态过滤(draft/active/deprecated)
|
||||
scope: 按适用范围过滤
|
||||
|
||||
Returns:
|
||||
MetadataFieldDefinition 列表
|
||||
"""
|
||||
stmt = select(MetadataFieldDefinition).where(
|
||||
MetadataFieldDefinition.tenant_id == tenant_id,
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(MetadataFieldDefinition.status == status)
|
||||
|
||||
if scope:
|
||||
stmt = stmt.where(MetadataFieldDefinition.scope.contains([scope]))
|
||||
|
||||
stmt = stmt.order_by(col(MetadataFieldDefinition.created_at).desc())
|
||||
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_field_definition(
|
||||
self,
|
||||
tenant_id: str,
|
||||
field_id: str,
|
||||
) -> MetadataFieldDefinition | None:
|
||||
"""
|
||||
获取单个字段定义
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
field_id: 字段定义 ID
|
||||
|
||||
Returns:
|
||||
MetadataFieldDefinition 或 None
|
||||
"""
|
||||
stmt = select(MetadataFieldDefinition).where(
|
||||
MetadataFieldDefinition.tenant_id == tenant_id,
|
||||
MetadataFieldDefinition.id == uuid.UUID(field_id),
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_field_definition_by_key(
|
||||
self,
|
||||
tenant_id: str,
|
||||
field_key: str,
|
||||
) -> MetadataFieldDefinition | None:
|
||||
"""
|
||||
通过 field_key 获取字段定义
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
field_key: 字段键名
|
||||
|
||||
Returns:
|
||||
MetadataFieldDefinition 或 None
|
||||
"""
|
||||
stmt = select(MetadataFieldDefinition).where(
|
||||
MetadataFieldDefinition.tenant_id == tenant_id,
|
||||
MetadataFieldDefinition.field_key == field_key,
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_field_definition(
|
||||
self,
|
||||
tenant_id: str,
|
||||
field_create: MetadataFieldDefinitionCreate,
|
||||
) -> MetadataFieldDefinition:
|
||||
"""
|
||||
[AC-IDSMETA-13] 创建元数据字段定义
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
field_create: 创建数据
|
||||
|
||||
Returns:
|
||||
创建的 MetadataFieldDefinition
|
||||
|
||||
Raises:
|
||||
ValueError: 如果 field_key 已存在或格式不正确
|
||||
"""
|
||||
if not self.FIELD_KEY_PATTERN.match(field_create.field_key):
|
||||
raise ValueError(
|
||||
f"field_key '{field_create.field_key}' 格式不正确,"
|
||||
"仅允许小写字母、数字和下划线"
|
||||
)
|
||||
|
||||
existing = await self.get_field_definition_by_key(tenant_id, field_create.field_key)
|
||||
if existing:
|
||||
raise ValueError(f"field_key '{field_create.field_key}' 已存在")
|
||||
|
||||
self._validate_field_type_and_options(
|
||||
field_create.type,
|
||||
field_create.options,
|
||||
field_create.field_key,
|
||||
)
|
||||
|
||||
field = MetadataFieldDefinition(
|
||||
tenant_id=tenant_id,
|
||||
field_key=field_create.field_key,
|
||||
label=field_create.label,
|
||||
type=field_create.type,
|
||||
required=field_create.required,
|
||||
options=field_create.options,
|
||||
default_value=field_create.default_value,
|
||||
scope=field_create.scope,
|
||||
is_filterable=field_create.is_filterable,
|
||||
is_rank_feature=field_create.is_rank_feature,
|
||||
status=field_create.status,
|
||||
version=1,
|
||||
)
|
||||
|
||||
self._session.add(field)
|
||||
await self._session.flush()
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-13] Created field definition: tenant={tenant_id}, "
|
||||
f"field_key={field.field_key}, status={field.status}"
|
||||
)
|
||||
|
||||
return field
|
||||
|
||||
async def update_field_definition(
|
||||
self,
|
||||
tenant_id: str,
|
||||
field_id: str,
|
||||
field_update: MetadataFieldDefinitionUpdate,
|
||||
) -> MetadataFieldDefinition | None:
|
||||
"""
|
||||
[AC-IDSMETA-14] 更新元数据字段定义
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
field_id: 字段定义 ID
|
||||
field_update: 更新数据
|
||||
|
||||
Returns:
|
||||
更新后的 MetadataFieldDefinition 或 None
|
||||
"""
|
||||
field = await self.get_field_definition(tenant_id, field_id)
|
||||
if not field:
|
||||
return None
|
||||
|
||||
if field_update.label is not None:
|
||||
field.label = field_update.label
|
||||
if field_update.required is not None:
|
||||
field.required = field_update.required
|
||||
if field_update.options is not None:
|
||||
self._validate_field_type_and_options(field.type, field_update.options, field.field_key)
|
||||
field.options = field_update.options
|
||||
if field_update.default_value is not None:
|
||||
field.default_value = field_update.default_value
|
||||
if field_update.scope is not None:
|
||||
field.scope = field_update.scope
|
||||
if field_update.is_filterable is not None:
|
||||
field.is_filterable = field_update.is_filterable
|
||||
if field_update.is_rank_feature is not None:
|
||||
field.is_rank_feature = field_update.is_rank_feature
|
||||
if field_update.status is not None:
|
||||
old_status = field.status
|
||||
field.status = field_update.status
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Field status changed: tenant={tenant_id}, "
|
||||
f"field_key={field.field_key}, {old_status} -> {field.status}"
|
||||
)
|
||||
|
||||
field.version += 1
|
||||
field.updated_at = datetime.utcnow()
|
||||
await self._session.flush()
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Updated field definition: tenant={tenant_id}, "
|
||||
f"field_id={field_id}, version={field.version}"
|
||||
)
|
||||
|
||||
return field
|
||||
|
||||
async def get_active_field_definitions(
|
||||
self,
|
||||
tenant_id: str,
|
||||
scope: str | None = None,
|
||||
) -> list[MetadataFieldDefinition]:
|
||||
"""
|
||||
[AC-IDSMETA-14] 获取活跃状态的字段定义(用于新建对象时选择)
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
scope: 按适用范围过滤
|
||||
|
||||
Returns:
|
||||
状态为 active 的 MetadataFieldDefinition 列表
|
||||
"""
|
||||
return await self.list_field_definitions(
|
||||
tenant_id,
|
||||
status=MetadataFieldStatus.ACTIVE.value,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
async def get_field_definitions_for_read(
|
||||
self,
|
||||
tenant_id: str,
|
||||
scope: str | None = None,
|
||||
) -> list[MetadataFieldDefinition]:
|
||||
"""
|
||||
[AC-IDSMETA-14] 获取可用于读取的字段定义(active + deprecated,不含 draft)
|
||||
用于历史数据读取和展示
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
scope: 按适用范围过滤
|
||||
|
||||
Returns:
|
||||
状态为 active 或 deprecated 的 MetadataFieldDefinition 列表
|
||||
"""
|
||||
stmt = select(MetadataFieldDefinition).where(
|
||||
MetadataFieldDefinition.tenant_id == tenant_id,
|
||||
MetadataFieldDefinition.status.in_([
|
||||
MetadataFieldStatus.ACTIVE.value,
|
||||
MetadataFieldStatus.DEPRECATED.value,
|
||||
]),
|
||||
)
|
||||
|
||||
if scope:
|
||||
stmt = stmt.where(MetadataFieldDefinition.scope.contains([scope]))
|
||||
|
||||
stmt = stmt.order_by(col(MetadataFieldDefinition.created_at).desc())
|
||||
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def validate_metadata_for_create(
|
||||
self,
|
||||
tenant_id: str,
|
||||
metadata: dict[str, Any],
|
||||
scope: str,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象
|
||||
|
||||
检查:
|
||||
1. 所有 required 字段是否已填写
|
||||
2. 字段是否为 active 状态(deprecated 字段禁止用于新建)
|
||||
3. 值类型是否正确
|
||||
4. enum/array_enum 类型的值是否在 options 中
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
metadata: 元数据字典
|
||||
scope: 对象类型范围
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误消息列表)
|
||||
"""
|
||||
active_fields = await self.get_active_field_definitions(tenant_id, scope)
|
||||
active_field_map = {f.field_key: f for f in active_fields}
|
||||
|
||||
errors = []
|
||||
|
||||
for field_key, field_def in active_field_map.items():
|
||||
if field_def.required and field_key not in metadata:
|
||||
errors.append(f"必填字段 '{field_def.label}' ({field_key}) 未填写")
|
||||
continue
|
||||
|
||||
for field_key, value in metadata.items():
|
||||
if field_key not in active_field_map:
|
||||
deprecated_field = await self.get_field_definition_by_key(tenant_id, field_key)
|
||||
if deprecated_field and deprecated_field.status == MetadataFieldStatus.DEPRECATED.value:
|
||||
errors.append(
|
||||
f"字段 '{deprecated_field.label}' ({field_key}) 已废弃,"
|
||||
"不能用于新建对象"
|
||||
)
|
||||
continue
|
||||
|
||||
field_def = active_field_map[field_key]
|
||||
type_errors = self._validate_field_value(field_def, value)
|
||||
errors.extend(type_errors)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
async def validate_metadata_for_update(
|
||||
self,
|
||||
tenant_id: str,
|
||||
metadata: dict[str, Any],
|
||||
scope: str,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
[AC-IDSMETA-14] 验证元数据是否可用于更新对象
|
||||
|
||||
与新建不同,更新时允许保留 deprecated 字段的值(但不能新增)
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
metadata: 元数据字典
|
||||
scope: 对象类型范围
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误消息列表)
|
||||
"""
|
||||
readable_fields = await self.get_field_definitions_for_read(tenant_id, scope)
|
||||
readable_field_map = {f.field_key: f for f in readable_fields}
|
||||
|
||||
active_fields = await self.get_active_field_definitions(tenant_id, scope)
|
||||
active_field_map = {f.field_key: f for f in active_fields}
|
||||
|
||||
errors = []
|
||||
|
||||
for field_key, field_def in active_field_map.items():
|
||||
if field_def.required and field_key not in metadata:
|
||||
errors.append(f"必填字段 '{field_def.label}' ({field_key}) 未填写")
|
||||
|
||||
for field_key, value in metadata.items():
|
||||
if field_key not in readable_field_map:
|
||||
errors.append(f"未知字段 '{field_key}'")
|
||||
continue
|
||||
|
||||
field_def = readable_field_map[field_key]
|
||||
type_errors = self._validate_field_value(field_def, value)
|
||||
errors.extend(type_errors)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def _validate_field_value(
|
||||
self,
|
||||
field_def: MetadataFieldDefinition,
|
||||
value: Any,
|
||||
) -> list[str]:
|
||||
"""验证字段值类型和选项"""
|
||||
errors = []
|
||||
field_key = field_def.field_key
|
||||
field_type = field_def.type
|
||||
|
||||
if value is None:
|
||||
return errors
|
||||
|
||||
if field_type == MetadataFieldType.NUMBER.value:
|
||||
if not isinstance(value, (int, float)):
|
||||
try:
|
||||
float(value)
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"字段 '{field_def.label}' ({field_key}) 应为数字类型")
|
||||
|
||||
elif field_type == MetadataFieldType.BOOLEAN.value:
|
||||
if not isinstance(value, bool):
|
||||
if value not in ["true", "false", "1", "0", 1, 0]:
|
||||
errors.append(f"字段 '{field_def.label}' ({field_key}) 应为布尔类型")
|
||||
|
||||
elif field_type == MetadataFieldType.ENUM.value:
|
||||
if field_def.options and value not in field_def.options:
|
||||
errors.append(
|
||||
f"字段 '{field_def.label}' ({field_key}) 的值 '{value}' "
|
||||
f"不在允许选项中: {field_def.options}"
|
||||
)
|
||||
|
||||
elif field_type == MetadataFieldType.ARRAY_ENUM.value:
|
||||
if not isinstance(value, list):
|
||||
errors.append(f"字段 '{field_def.label}' ({field_key}) 应为数组类型")
|
||||
elif field_def.options:
|
||||
for v in value:
|
||||
if v not in field_def.options:
|
||||
errors.append(
|
||||
f"字段 '{field_def.label}' ({field_key}) 的值 '{v}' "
|
||||
f"不在允许选项中: {field_def.options}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
def _validate_field_type_and_options(
|
||||
self,
|
||||
field_type: str,
|
||||
options: list[str] | None,
|
||||
field_key: str,
|
||||
) -> None:
|
||||
"""验证字段类型和选项的一致性"""
|
||||
if field_type in [MetadataFieldType.ENUM.value, MetadataFieldType.ARRAY_ENUM.value]:
|
||||
if not options or len(options) == 0:
|
||||
raise ValueError(
|
||||
f"字段 '{field_key}' 类型为 {field_type},必须提供 options"
|
||||
)
|
||||
if len(options) != len(set(options)):
|
||||
raise ValueError(
|
||||
f"字段 '{field_key}' 的 options 存在重复值"
|
||||
)
|
||||
|
||||
async def get_field_definitions_map(
|
||||
self,
|
||||
tenant_id: str,
|
||||
scope: str | None = None,
|
||||
include_deprecated: bool = False,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
获取字段定义映射,用于前端动态渲染表单
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
scope: 按适用范围过滤
|
||||
include_deprecated: 是否包含 deprecated 字段
|
||||
|
||||
Returns:
|
||||
字段键名到字段定义的映射
|
||||
"""
|
||||
if include_deprecated:
|
||||
fields = await self.get_field_definitions_for_read(tenant_id, scope)
|
||||
else:
|
||||
fields = await self.get_active_field_definitions(tenant_id, scope)
|
||||
|
||||
return {
|
||||
f.field_key: {
|
||||
"id": str(f.id),
|
||||
"field_key": f.field_key,
|
||||
"label": f.label,
|
||||
"type": f.type,
|
||||
"required": f.required,
|
||||
"options": f.options,
|
||||
"default": f.default_value,
|
||||
"scope": f.scope,
|
||||
"is_filterable": f.is_filterable,
|
||||
"is_rank_feature": f.is_rank_feature,
|
||||
"status": f.status,
|
||||
}
|
||||
for f in fields
|
||||
}
|
||||
Loading…
Reference in New Issue