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'),
|
component: () => import('@/views/admin/knowledge-base/index.vue'),
|
||||||
meta: { title: '多知识库管理' }
|
meta: { title: '多知识库管理' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/metadata-schemas',
|
||||||
|
name: 'MetadataSchema',
|
||||||
|
component: () => import('@/views/admin/metadata-schema/index.vue'),
|
||||||
|
meta: { title: '元数据模式配置' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/intent-rules',
|
path: '/admin/intent-rules',
|
||||||
name: 'IntentRule',
|
name: 'IntentRule',
|
||||||
|
|
@ -71,6 +77,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
component: () => import('@/views/admin/guardrail/index.vue'),
|
component: () => import('@/views/admin/guardrail/index.vue'),
|
||||||
meta: { title: '输出护栏管理' }
|
meta: { title: '输出护栏管理' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/decomposition-templates',
|
||||||
|
name: 'DecompositionTemplate',
|
||||||
|
component: () => import('@/views/admin/decomposition-template/index.vue'),
|
||||||
|
meta: { title: '拆解模板管理' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/monitoring/intent-rules',
|
path: '/admin/monitoring/intent-rules',
|
||||||
name: 'IntentRuleMonitoring',
|
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 {
|
export interface IntentRule {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -11,6 +13,7 @@ export interface IntentRule {
|
||||||
transfer_message?: string
|
transfer_message?: string
|
||||||
hit_count: number
|
hit_count: number
|
||||||
is_enabled: boolean
|
is_enabled: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +29,7 @@ export interface IntentRuleCreate {
|
||||||
flow_id?: string
|
flow_id?: string
|
||||||
transfer_message?: string
|
transfer_message?: string
|
||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntentRuleUpdate {
|
export interface IntentRuleUpdate {
|
||||||
|
|
@ -39,6 +43,7 @@ export interface IntentRuleUpdate {
|
||||||
flow_id?: string
|
flow_id?: string
|
||||||
transfer_message?: string
|
transfer_message?: string
|
||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntentRuleListResponse {
|
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 {
|
export interface PromptTemplate {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
scene: string
|
scene: string
|
||||||
description?: string
|
description?: string
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
published_version?: PromptVersionInfo
|
published_version?: PromptVersionInfo
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|
@ -24,6 +27,7 @@ export interface PromptTemplateDetail {
|
||||||
current_content?: string
|
current_content?: string
|
||||||
variables?: PromptVariable[]
|
variables?: PromptVariable[]
|
||||||
versions?: PromptVersion[]
|
versions?: PromptVersion[]
|
||||||
|
metadata?: MetadataPayload
|
||||||
published_version?: PromptVersionInfo
|
published_version?: PromptVersionInfo
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|
@ -51,6 +55,7 @@ export interface PromptTemplateCreate {
|
||||||
system_instruction: string
|
system_instruction: string
|
||||||
variables?: PromptVariable[]
|
variables?: PromptVariable[]
|
||||||
is_default?: boolean
|
is_default?: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptTemplateUpdate {
|
export interface PromptTemplateUpdate {
|
||||||
|
|
@ -59,6 +64,7 @@ export interface PromptTemplateUpdate {
|
||||||
description?: string
|
description?: string
|
||||||
system_instruction?: string
|
system_instruction?: string
|
||||||
variables?: PromptVariable[]
|
variables?: PromptVariable[]
|
||||||
|
metadata?: MetadataPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptTemplateListResponse {
|
export interface PromptTemplateListResponse {
|
||||||
|
|
@ -84,8 +90,11 @@ export const SCENE_OPTIONS = [
|
||||||
|
|
||||||
export const BUILTIN_VARIABLES: PromptVariable[] = [
|
export const BUILTIN_VARIABLES: PromptVariable[] = [
|
||||||
{ name: 'persona_name', description: 'AI 人设名称', default_value: 'AI助手' },
|
{ 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: 'current_time', description: '当前时间' },
|
||||||
{ name: 'channel_type', description: '渠道类型(web/wechat/app)' },
|
{ name: 'channel_type', description: '渠道类型(web/wechat/phone/app)' },
|
||||||
{ name: 'user_name', description: '用户名称' },
|
{ name: 'user_name', description: '用户名称' },
|
||||||
{ name: 'context', description: '检索上下文' },
|
{ name: 'context', description: '检索上下文' },
|
||||||
{ name: 'query', description: '用户问题' },
|
{ name: 'query', description: '用户问题' },
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { MetadataPayload } from '@/types/metadata'
|
||||||
|
|
||||||
export interface ScriptFlow {
|
export interface ScriptFlow {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -5,6 +7,7 @@ export interface ScriptFlow {
|
||||||
step_count: number
|
step_count: number
|
||||||
is_enabled: boolean
|
is_enabled: boolean
|
||||||
linked_rule_count: number
|
linked_rule_count: number
|
||||||
|
metadata?: MetadataPayload
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -15,10 +18,13 @@ export interface ScriptFlowDetail {
|
||||||
description?: string
|
description?: string
|
||||||
steps: FlowStep[]
|
steps: FlowStep[]
|
||||||
is_enabled: boolean
|
is_enabled: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScriptMode = 'fixed' | 'flexible' | 'template'
|
||||||
|
|
||||||
export interface FlowStep {
|
export interface FlowStep {
|
||||||
step_id: string
|
step_id: string
|
||||||
step_no: number
|
step_no: number
|
||||||
|
|
@ -27,11 +33,18 @@ export interface FlowStep {
|
||||||
timeout_seconds?: number
|
timeout_seconds?: number
|
||||||
timeout_action?: 'repeat' | 'skip' | 'transfer'
|
timeout_action?: 'repeat' | 'skip' | 'transfer'
|
||||||
next_conditions?: NextCondition[]
|
next_conditions?: NextCondition[]
|
||||||
|
default_next?: number
|
||||||
|
script_mode?: ScriptMode
|
||||||
|
intent?: string
|
||||||
|
intent_description?: string
|
||||||
|
script_constraints?: string[]
|
||||||
|
expected_variables?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NextCondition {
|
export interface NextCondition {
|
||||||
keywords: string[]
|
keywords?: string[]
|
||||||
target_step_id: string
|
pattern?: string
|
||||||
|
goto_step: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptFlowCreate {
|
export interface ScriptFlowCreate {
|
||||||
|
|
@ -39,6 +52,7 @@ export interface ScriptFlowCreate {
|
||||||
description?: string
|
description?: string
|
||||||
steps: FlowStep[]
|
steps: FlowStep[]
|
||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptFlowUpdate {
|
export interface ScriptFlowUpdate {
|
||||||
|
|
@ -46,6 +60,7 @@ export interface ScriptFlowUpdate {
|
||||||
description?: string
|
description?: string
|
||||||
steps?: FlowStep[]
|
steps?: FlowStep[]
|
||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
|
metadata?: MetadataPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptFlowListResponse {
|
export interface ScriptFlowListResponse {
|
||||||
|
|
@ -57,3 +72,17 @@ export const TIMEOUT_ACTION_OPTIONS = [
|
||||||
{ value: 'skip', label: '跳过进入下一步' },
|
{ value: 'skip', label: '跳过进入下一步' },
|
||||||
{ value: 'transfer', 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="header-content">
|
||||||
<div class="title-section">
|
<div class="title-section">
|
||||||
<h1 class="page-title">意图规则管理</h1>
|
<h1 class="page-title">意图规则管理</h1>
|
||||||
<p class="page-desc">配置意图识别规则,让特定问题走固定回复或话术流程。</p>
|
<p class="page-desc">配置意图识别规则,让特定问题走固定回复或话术流程。[AC-IDSMETA-16]</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-select v-model="filterResponseType" placeholder="按响应类型筛选" clearable style="width: 150px;">
|
<el-select v-model="filterResponseType" placeholder="按响应类型筛选" clearable style="width: 150px;">
|
||||||
|
|
@ -48,6 +48,14 @@
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="priority" label="优先级" width="80" sortable />
|
<el-table-column prop="priority" label="优先级" width="80" sortable />
|
||||||
<el-table-column prop="hit_count" label="命中次数" width="100" 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">
|
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch
|
<el-switch
|
||||||
|
|
@ -79,7 +87,7 @@
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="isEdit ? '编辑规则' : '新建规则'"
|
:title="isEdit ? '编辑规则' : '新建规则'"
|
||||||
width="700px"
|
width="800px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
|
|
@ -168,6 +176,16 @@
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</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>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
|
@ -198,10 +216,12 @@ import {
|
||||||
} from '@/api/intent-rule'
|
} from '@/api/intent-rule'
|
||||||
import { listKnowledgeBases } from '@/api/knowledge-base'
|
import { listKnowledgeBases } from '@/api/knowledge-base'
|
||||||
import { listScriptFlows } from '@/api/script-flow'
|
import { listScriptFlows } from '@/api/script-flow'
|
||||||
|
import { MetadataForm } from '@/components/metadata'
|
||||||
import { RESPONSE_TYPE_OPTIONS, RESPONSE_TYPE_MAP } from '@/types/intent-rule'
|
import { RESPONSE_TYPE_OPTIONS, RESPONSE_TYPE_MAP } from '@/types/intent-rule'
|
||||||
import type { IntentRule, IntentRuleCreate, IntentRuleUpdate } from '@/types/intent-rule'
|
import type { IntentRule, IntentRuleCreate, IntentRuleUpdate } from '@/types/intent-rule'
|
||||||
import type { KnowledgeBase } from '@/types/knowledge-base'
|
import type { KnowledgeBase } from '@/types/knowledge-base'
|
||||||
import type { ScriptFlow } from '@/types/script-flow'
|
import type { ScriptFlow } from '@/types/script-flow'
|
||||||
|
import type { MetadataPayload } from '@/types/metadata'
|
||||||
import KeywordInput from './components/KeywordInput.vue'
|
import KeywordInput from './components/KeywordInput.vue'
|
||||||
import PatternInput from './components/PatternInput.vue'
|
import PatternInput from './components/PatternInput.vue'
|
||||||
import TestDialog from './components/TestDialog.vue'
|
import TestDialog from './components/TestDialog.vue'
|
||||||
|
|
@ -215,6 +235,7 @@ const dialogVisible = ref(false)
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
const metadataFormRef = ref()
|
||||||
const currentEditId = ref('')
|
const currentEditId = ref('')
|
||||||
const testDialogVisible = ref(false)
|
const testDialogVisible = ref(false)
|
||||||
const testRuleId = ref('')
|
const testRuleId = ref('')
|
||||||
|
|
@ -231,7 +252,8 @@ const defaultFormData = (): IntentRuleCreate => ({
|
||||||
target_kb_ids: [],
|
target_kb_ids: [],
|
||||||
flow_id: '',
|
flow_id: '',
|
||||||
transfer_message: '',
|
transfer_message: '',
|
||||||
is_enabled: true
|
is_enabled: true,
|
||||||
|
metadata: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const formData = ref<IntentRuleCreate>(defaultFormData())
|
const formData = ref<IntentRuleCreate>(defaultFormData())
|
||||||
|
|
@ -308,7 +330,8 @@ const handleEdit = (row: IntentRule) => {
|
||||||
target_kb_ids: row.target_kb_ids || [],
|
target_kb_ids: row.target_kb_ids || [],
|
||||||
flow_id: row.flow_id || '',
|
flow_id: row.flow_id || '',
|
||||||
transfer_message: row.transfer_message || '',
|
transfer_message: row.transfer_message || '',
|
||||||
is_enabled: row.is_enabled
|
is_enabled: row.is_enabled,
|
||||||
|
metadata: row.metadata || {}
|
||||||
}
|
}
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
@ -361,6 +384,14 @@ const handleSubmit = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadataFormRef.value) {
|
||||||
|
const validation = await metadataFormRef.value.validate()
|
||||||
|
if (!validation.valid) {
|
||||||
|
ElMessage.warning('请完善必填的元数据字段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
|
|
@ -370,7 +401,8 @@ const handleSubmit = async () => {
|
||||||
patterns: formData.value.patterns,
|
patterns: formData.value.patterns,
|
||||||
response_type: formData.value.response_type,
|
response_type: formData.value.response_type,
|
||||||
priority: formData.value.priority,
|
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') {
|
if (formData.value.response_type === 'fixed') {
|
||||||
updateData.fixed_reply = formData.value.fixed_reply
|
updateData.fixed_reply = formData.value.fixed_reply
|
||||||
|
|
@ -464,6 +496,10 @@ onMounted(() => {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-metadata {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
.slide-fade-enter-active {
|
.slide-fade-enter-active {
|
||||||
transition: all 0.3s ease-out;
|
transition: all 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="document-list">
|
<div class="document-list">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<el-upload
|
<el-button type="primary" @click="handleUploadClick">
|
||||||
ref="uploadRef"
|
|
||||||
:action="uploadUrl"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
:data="{ kb_id: kbId }"
|
|
||||||
:show-file-list="false"
|
|
||||||
:before-upload="beforeUpload"
|
|
||||||
:on-success="handleUploadSuccess"
|
|
||||||
:on-error="handleUploadError"
|
|
||||||
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx"
|
|
||||||
>
|
|
||||||
<el-button type="primary">
|
|
||||||
<el-icon><Upload /></el-icon>
|
<el-icon><Upload /></el-icon>
|
||||||
上传文档
|
上传文档
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-upload>
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="documents" v-loading="loading" stripe>
|
<el-table :data="documents" v-loading="loading" stripe>
|
||||||
|
|
@ -28,13 +23,36 @@
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column prop="createdAt" label="上传时间" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ formatDate(row.createdAt) }}
|
{{ formatDate(row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="{ row }">
|
<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 type="danger" link size="small" @click="handleDelete(row)">
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
@ -51,17 +69,82 @@
|
||||||
@current-change="loadDocuments"
|
@current-change="loadDocuments"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 { 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'
|
import { useTenantStore } from '@/stores/tenant'
|
||||||
|
|
||||||
|
interface DocumentWithMetadata extends DocType {
|
||||||
|
metadata?: MetadataPayload
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
kbId: string
|
kbId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -72,7 +155,7 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const tenantStore = useTenantStore()
|
const tenantStore = useTenantStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const documents = ref<Document[]>([])
|
const documents = ref<DocumentWithMetadata[]>([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
@ -81,14 +164,28 @@ const pagination = ref({
|
||||||
totalPages: 0
|
totalPages: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const uploadUrl = computed(() => {
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
const uploadDialogVisible = ref(false)
|
||||||
return `${baseUrl}/admin/kb/documents`
|
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(() => ({
|
const editForm = ref({
|
||||||
'X-Tenant-Id': tenantStore.currentTenantId
|
metadata: {} as MetadataPayload
|
||||||
}))
|
})
|
||||||
|
|
||||||
|
const uploadRules = {
|
||||||
|
file: [{ required: true, message: '请选择文件', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
const getStatusType = (status: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
const getStatusType = (status: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||||
const typeMap: Record<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 () => {
|
const loadDocuments = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -139,46 +257,128 @@ const loadDocuments = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeUpload = (file: File) => {
|
const handleUploadClick = () => {
|
||||||
const allowedTypes = [
|
fileInputRef.value?.click()
|
||||||
'text/plain',
|
}
|
||||||
'text/markdown',
|
|
||||||
'application/pdf',
|
const handleFileSelect = (event: Event) => {
|
||||||
'application/msword',
|
const target = event.target as HTMLInputElement
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
const file = target.files?.[0]
|
||||||
'application/vnd.ms-excel',
|
if (!file) return
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
||||||
]
|
|
||||||
const allowedExtensions = ['.txt', '.md', '.pdf', '.doc', '.docx', '.xls', '.xlsx']
|
const allowedExtensions = ['.txt', '.md', '.pdf', '.doc', '.docx', '.xls', '.xlsx']
|
||||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||||
|
|
||||||
if (!allowedExtensions.includes(ext)) {
|
if (!allowedExtensions.includes(ext)) {
|
||||||
ElMessage.error('不支持的文件格式')
|
ElMessage.error('不支持的文件格式')
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 50 * 1024 * 1024
|
const maxSize = 50 * 1024 * 1024
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
ElMessage.error('文件大小不能超过 50MB')
|
ElMessage.error('文件大小不能超过 50MB')
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
selectedFile.value = file
|
||||||
|
uploadForm.value.metadata = {}
|
||||||
|
uploadDialogVisible.value = true
|
||||||
|
|
||||||
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUploadSuccess = (response: any) => {
|
const handleUpload = async () => {
|
||||||
if (response.jobId) {
|
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('文档上传成功,正在处理中...')
|
ElMessage.success('文档上传成功,正在处理中...')
|
||||||
emit('upload-success')
|
emit('upload-success')
|
||||||
loadDocuments()
|
loadDocuments()
|
||||||
pollJobStatus(response.jobId)
|
pollJobStatus(result.jobId)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error('上传失败')
|
ElMessage.error(result.message || '上传失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('上传失败,请重试')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
uploadDialogVisible.value = false
|
||||||
|
selectedFile.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUploadError = () => {
|
const handleEditMetadata = (doc: DocumentWithMetadata) => {
|
||||||
ElMessage.error('上传失败,请重试')
|
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) => {
|
const pollJobStatus = async (jobId: string) => {
|
||||||
|
|
@ -208,7 +408,7 @@ const pollJobStatus = async (jobId: string) => {
|
||||||
setTimeout(poll, 2000)
|
setTimeout(poll, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (row: Document) => {
|
const handleDelete = async (row: DocumentWithMetadata) => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定要删除该文档吗?', '确认删除', {
|
await ElMessageBox.confirm('确定要删除该文档吗?', '确认删除', {
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
|
|
@ -245,4 +445,27 @@ onMounted(() => {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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>
|
</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 ConversationDetail,
|
||||||
type ExportTaskResponse
|
type ExportTaskResponse
|
||||||
} from '@/api/monitoring'
|
} from '@/api/monitoring'
|
||||||
import request from '@/utils/request'
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref<ConversationItem[]>([])
|
const tableData = ref<ConversationItem[]>([])
|
||||||
|
|
@ -433,7 +432,8 @@ const downloadExport = () => {
|
||||||
if (exportTask.value) {
|
if (exportTask.value) {
|
||||||
const url = getExportDownloadUrl(exportTask.value.taskId)
|
const url = getExportDownloadUrl(exportTask.value.taskId)
|
||||||
const link = document.createElement('a')
|
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'
|
link.download = exportTask.value.fileName || 'export.json'
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="title-section">
|
<div class="title-section">
|
||||||
<h1 class="page-title">Prompt 模板管理</h1>
|
<h1 class="page-title">Prompt 模板管理</h1>
|
||||||
<p class="page-desc">管理不同场景的 Prompt 模板,支持版本管理和一键回滚。</p>
|
<p class="page-desc">管理不同场景的 Prompt 模板,支持版本管理和一键回滚。[AC-IDSMETA-16]</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 150px;">
|
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 150px;">
|
||||||
|
|
@ -43,6 +43,14 @@
|
||||||
<span v-else class="no-version">未发布</span>
|
<span v-else class="no-version">未发布</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ formatDate(row.updated_at) }}
|
{{ formatDate(row.updated_at) }}
|
||||||
|
|
@ -78,7 +86,7 @@
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="isEdit ? '编辑模板' : '新建模板'"
|
:title="isEdit ? '编辑模板' : '新建模板'"
|
||||||
width="900px"
|
width="950px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
|
|
@ -100,6 +108,19 @@
|
||||||
<el-form-item label="描述">
|
<el-form-item label="描述">
|
||||||
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
|
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
|
||||||
</el-form-item>
|
</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">
|
<el-form-item label="系统指令" prop="system_instruction">
|
||||||
<div class="content-editor">
|
<div class="content-editor">
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
|
|
@ -183,6 +204,7 @@ import {
|
||||||
publishPromptTemplate,
|
publishPromptTemplate,
|
||||||
rollbackPromptTemplate
|
rollbackPromptTemplate
|
||||||
} from '@/api/prompt-template'
|
} from '@/api/prompt-template'
|
||||||
|
import { MetadataForm } from '@/components/metadata'
|
||||||
import { SCENE_OPTIONS, BUILTIN_VARIABLES } from '@/types/prompt-template'
|
import { SCENE_OPTIONS, BUILTIN_VARIABLES } from '@/types/prompt-template'
|
||||||
import type { PromptTemplate, PromptTemplateDetail, PromptTemplateCreate, PromptTemplateUpdate, PromptVariable } from '@/types/prompt-template'
|
import type { PromptTemplate, PromptTemplateDetail, PromptTemplateCreate, PromptTemplateUpdate, PromptVariable } from '@/types/prompt-template'
|
||||||
import TemplateDetail from './components/TemplateDetail.vue'
|
import TemplateDetail from './components/TemplateDetail.vue'
|
||||||
|
|
@ -197,6 +219,7 @@ const detailDrawer = ref(false)
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
const metadataFormRef = ref()
|
||||||
const currentTemplate = ref<PromptTemplateDetail | null>(null)
|
const currentTemplate = ref<PromptTemplateDetail | null>(null)
|
||||||
const currentEditId = ref('')
|
const currentEditId = ref('')
|
||||||
const previewDialogVisible = ref(false)
|
const previewDialogVisible = ref(false)
|
||||||
|
|
@ -208,7 +231,8 @@ const formData = ref<PromptTemplateCreate>({
|
||||||
scene: '',
|
scene: '',
|
||||||
description: '',
|
description: '',
|
||||||
system_instruction: '',
|
system_instruction: '',
|
||||||
variables: []
|
variables: [],
|
||||||
|
metadata: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
|
|
@ -270,7 +294,8 @@ const handleCreate = () => {
|
||||||
scene: '',
|
scene: '',
|
||||||
description: '',
|
description: '',
|
||||||
system_instruction: '',
|
system_instruction: '',
|
||||||
variables: []
|
variables: [],
|
||||||
|
metadata: {}
|
||||||
}
|
}
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +310,8 @@ const handleEdit = async (row: PromptTemplate) => {
|
||||||
scene: detail.scene,
|
scene: detail.scene,
|
||||||
description: detail.description || '',
|
description: detail.description || '',
|
||||||
system_instruction: detail.current_content || '',
|
system_instruction: detail.current_content || '',
|
||||||
variables: detail.variables || []
|
variables: detail.variables || [],
|
||||||
|
metadata: detail.metadata || {}
|
||||||
}
|
}
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -358,6 +384,14 @@ const handleSubmit = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadataFormRef.value) {
|
||||||
|
const validation = await metadataFormRef.value.validate()
|
||||||
|
if (!validation.valid) {
|
||||||
|
ElMessage.warning('请完善必填的元数据字段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
|
|
@ -366,7 +400,8 @@ const handleSubmit = async () => {
|
||||||
scene: formData.value.scene,
|
scene: formData.value.scene,
|
||||||
description: formData.value.description,
|
description: formData.value.description,
|
||||||
system_instruction: formData.value.system_instruction,
|
system_instruction: formData.value.system_instruction,
|
||||||
variables: formData.value.variables
|
variables: formData.value.variables,
|
||||||
|
metadata: formData.value.metadata
|
||||||
}
|
}
|
||||||
await updatePromptTemplate(currentEditId.value, updateData)
|
await updatePromptTemplate(currentEditId.value, updateData)
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
|
|
@ -509,6 +544,10 @@ onMounted(() => {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-metadata {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
.content-editor {
|
.content-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
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="header-content">
|
||||||
<div class="title-section">
|
<div class="title-section">
|
||||||
<h1 class="page-title">话术流程管理</h1>
|
<h1 class="page-title">话术流程管理</h1>
|
||||||
<p class="page-desc">编排多步骤的话术流程,引导用户按固定步骤完成信息收集。</p>
|
<p class="page-desc">编排多步骤的话术流程,引导用户按固定步骤完成信息收集。[AC-IDSMETA-16]</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button type="primary" @click="handleCreate">
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
|
@ -25,6 +25,14 @@
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="step_count" label="步骤数" width="100" />
|
<el-table-column prop="step_count" label="步骤数" width="100" />
|
||||||
<el-table-column prop="linked_rule_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">
|
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch
|
<el-switch
|
||||||
|
|
@ -65,7 +73,7 @@
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="isEdit ? '编辑流程' : '新建流程'"
|
:title="isEdit ? '编辑流程' : '新建流程'"
|
||||||
width="900px"
|
width="950px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
|
|
@ -86,6 +94,16 @@
|
||||||
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述(可选)" />
|
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述(可选)" />
|
||||||
</el-form-item>
|
</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>
|
<el-divider content-position="left">步骤配置</el-divider>
|
||||||
|
|
||||||
<div class="steps-editor">
|
<div class="steps-editor">
|
||||||
|
|
@ -107,14 +125,107 @@
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<el-form-item label="话术内容">
|
<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
|
<el-input
|
||||||
v-model="element.content"
|
v-model="element.content"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="3"
|
:rows="3"
|
||||||
placeholder="请输入话术内容,支持 {{variable}} 占位符"
|
placeholder="请输入固定话术内容"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-row :gutter="16">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="等待输入">
|
<el-form-item label="等待输入">
|
||||||
|
|
@ -139,6 +250,78 @@
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -172,7 +355,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 draggable from 'vuedraggable'
|
||||||
import {
|
import {
|
||||||
listScriptFlows,
|
listScriptFlows,
|
||||||
|
|
@ -181,10 +364,12 @@ import {
|
||||||
deleteScriptFlow,
|
deleteScriptFlow,
|
||||||
getScriptFlow
|
getScriptFlow
|
||||||
} from '@/api/script-flow'
|
} from '@/api/script-flow'
|
||||||
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
import { MetadataForm } from '@/components/metadata'
|
||||||
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep } from '@/types/script-flow'
|
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 FlowPreview from './components/FlowPreview.vue'
|
||||||
import SimulateDialog from './components/SimulateDialog.vue'
|
import SimulateDialog from './components/SimulateDialog.vue'
|
||||||
|
import ConstraintManager from './components/ConstraintManager.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const flows = ref<ScriptFlow[]>([])
|
const flows = ref<ScriptFlow[]>([])
|
||||||
|
|
@ -196,6 +381,7 @@ const currentSimulateFlowName = ref('')
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
const metadataFormRef = ref()
|
||||||
const currentFlow = ref<ScriptFlowDetail | null>(null)
|
const currentFlow = ref<ScriptFlowDetail | null>(null)
|
||||||
const currentEditId = ref('')
|
const currentEditId = ref('')
|
||||||
|
|
||||||
|
|
@ -205,7 +391,8 @@ const defaultFormData = (): ScriptFlowCreate => ({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
steps: [],
|
steps: [],
|
||||||
is_enabled: true
|
is_enabled: true,
|
||||||
|
metadata: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const formData = ref<ScriptFlowCreate>(defaultFormData())
|
const formData = ref<ScriptFlowCreate>(defaultFormData())
|
||||||
|
|
@ -253,8 +440,14 @@ const handleEdit = async (row: ScriptFlow) => {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
name: detail.name,
|
name: detail.name,
|
||||||
description: detail.description || '',
|
description: detail.description || '',
|
||||||
steps: detail.steps || [],
|
steps: (detail.steps || []).map(step => ({
|
||||||
is_enabled: detail.is_enabled
|
...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
|
dialogVisible.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -312,17 +505,47 @@ const addStep = () => {
|
||||||
wait_input: true,
|
wait_input: true,
|
||||||
timeout_seconds: 30,
|
timeout_seconds: 30,
|
||||||
timeout_action: 'repeat',
|
timeout_action: 'repeat',
|
||||||
next_conditions: []
|
next_conditions: [],
|
||||||
|
script_mode: 'fixed',
|
||||||
|
script_constraints: [],
|
||||||
|
expected_variables: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeStep = (index: number) => {
|
const removeStep = (index: number) => {
|
||||||
|
const removedStepNo = index + 1
|
||||||
formData.value.steps.splice(index, 1)
|
formData.value.steps.splice(index, 1)
|
||||||
formData.value.steps.forEach((step, i) => {
|
formData.value.steps.forEach((step, i) => {
|
||||||
step.step_no = i + 1
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
|
|
@ -330,11 +553,45 @@ const handleSubmit = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadataFormRef.value) {
|
||||||
|
const validation = await metadataFormRef.value.validate()
|
||||||
|
if (!validation.valid) {
|
||||||
|
ElMessage.warning('请完善必填的元数据字段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.value.steps.length === 0) {
|
if (formData.value.steps.length === 0) {
|
||||||
ElMessage.warning('请至少添加一个步骤')
|
ElMessage.warning('请至少添加一个步骤')
|
||||||
return
|
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
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const submitData = {
|
const submitData = {
|
||||||
|
|
@ -412,6 +669,10 @@ onMounted(() => {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-metadata {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
.steps-editor {
|
.steps-editor {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -450,4 +711,32 @@ onMounted(() => {
|
||||||
.step-content {
|
.step-content {
|
||||||
padding: 16px;
|
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>
|
</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.api_key import router as api_key_router
|
||||||
from app.api.admin.dashboard import router as dashboard_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.embedding import router as embedding_router
|
||||||
from app.api.admin.flow_test import router as flow_test_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.guardrails import router as guardrails_router
|
||||||
from app.api.admin.intent_rules import router as intent_rules_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.kb import router as kb_router
|
||||||
from app.api.admin.llm import router as llm_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.monitoring import router as monitoring_router
|
||||||
from app.api.admin.prompt_templates import router as prompt_templates_router
|
from app.api.admin.prompt_templates import router as prompt_templates_router
|
||||||
from app.api.admin.rag import router as rag_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__ = [
|
__all__ = [
|
||||||
"api_key_router",
|
"api_key_router",
|
||||||
"dashboard_router",
|
"dashboard_router",
|
||||||
|
"decomposition_template_router",
|
||||||
"embedding_router",
|
"embedding_router",
|
||||||
"flow_test_router",
|
"flow_test_router",
|
||||||
"guardrails_router",
|
"guardrails_router",
|
||||||
"intent_rules_router",
|
"intent_rules_router",
|
||||||
"kb_router",
|
"kb_router",
|
||||||
"llm_router",
|
"llm_router",
|
||||||
|
"metadata_field_definition_router",
|
||||||
|
"metadata_schema_router",
|
||||||
"monitoring_router",
|
"monitoring_router",
|
||||||
"prompt_templates_router",
|
"prompt_templates_router",
|
||||||
"rag_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 (
|
from app.api.admin import (
|
||||||
api_key_router,
|
api_key_router,
|
||||||
dashboard_router,
|
dashboard_router,
|
||||||
|
decomposition_template_router,
|
||||||
embedding_router,
|
embedding_router,
|
||||||
flow_test_router,
|
flow_test_router,
|
||||||
guardrails_router,
|
guardrails_router,
|
||||||
intent_rules_router,
|
intent_rules_router,
|
||||||
kb_router,
|
kb_router,
|
||||||
llm_router,
|
llm_router,
|
||||||
|
metadata_field_definition_router,
|
||||||
|
metadata_schema_router,
|
||||||
monitoring_router,
|
monitoring_router,
|
||||||
prompt_templates_router,
|
prompt_templates_router,
|
||||||
rag_router,
|
rag_router,
|
||||||
|
|
@ -147,6 +150,7 @@ app.include_router(chat_router)
|
||||||
|
|
||||||
app.include_router(api_key_router)
|
app.include_router(api_key_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(decomposition_template_router)
|
||||||
app.include_router(embedding_router)
|
app.include_router(embedding_router)
|
||||||
app.include_router(flow_test_router)
|
app.include_router(flow_test_router)
|
||||||
app.include_router(guardrails_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_router)
|
||||||
app.include_router(kb_optimized_router)
|
app.include_router(kb_optimized_router)
|
||||||
app.include_router(llm_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(monitoring_router)
|
||||||
app.include_router(prompt_templates_router)
|
app.include_router(prompt_templates_router)
|
||||||
app.include_router(rag_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.
|
[AC-AISVC-51, AC-AISVC-52] Prompt template entity with tenant isolation.
|
||||||
Main table for storing template metadata.
|
Main table for storing template metadata.
|
||||||
|
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "prompt_templates"
|
__tablename__ = "prompt_templates"
|
||||||
|
|
@ -302,6 +303,11 @@ class PromptTemplate(SQLModel, table=True):
|
||||||
scene: str = Field(..., description="Scene tag: chat/rag_qa/greeting/farewell")
|
scene: str = Field(..., description="Scene tag: chat/rag_qa/greeting/farewell")
|
||||||
description: str | None = Field(default=None, description="Template description")
|
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")
|
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")
|
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||||
|
|
||||||
|
|
@ -350,6 +356,7 @@ class PromptTemplateCreate(SQLModel):
|
||||||
system_instruction: str
|
system_instruction: str
|
||||||
variables: list[dict[str, Any]] | None = None
|
variables: list[dict[str, Any]] | None = None
|
||||||
is_default: bool = False
|
is_default: bool = False
|
||||||
|
metadata_: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class PromptTemplateUpdate(SQLModel):
|
class PromptTemplateUpdate(SQLModel):
|
||||||
|
|
@ -361,6 +368,7 @@ class PromptTemplateUpdate(SQLModel):
|
||||||
system_instruction: str | None = None
|
system_instruction: str | None = None
|
||||||
variables: list[dict[str, Any]] | None = None
|
variables: list[dict[str, Any]] | None = None
|
||||||
is_default: bool | None = None
|
is_default: bool | None = None
|
||||||
|
metadata_: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ResponseType(str, Enum):
|
class ResponseType(str, Enum):
|
||||||
|
|
@ -375,6 +383,7 @@ class IntentRule(SQLModel, table=True):
|
||||||
"""
|
"""
|
||||||
[AC-AISVC-65] Intent rule entity with tenant isolation.
|
[AC-AISVC-65] Intent rule entity with tenant isolation.
|
||||||
Supports keyword and regex matching for intent recognition.
|
Supports keyword and regex matching for intent recognition.
|
||||||
|
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "intent_rules"
|
__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")
|
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")
|
is_enabled: bool = Field(default=True, description="Whether the rule is enabled")
|
||||||
hit_count: int = Field(default=0, description="Hit count for statistics")
|
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")
|
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update 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
|
flow_id: str | None = None
|
||||||
fixed_reply: str | None = None
|
fixed_reply: str | None = None
|
||||||
transfer_message: str | None = None
|
transfer_message: str | None = None
|
||||||
|
metadata_: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class IntentRuleUpdate(SQLModel):
|
class IntentRuleUpdate(SQLModel):
|
||||||
|
|
@ -438,6 +453,7 @@ class IntentRuleUpdate(SQLModel):
|
||||||
fixed_reply: str | None = None
|
fixed_reply: str | None = None
|
||||||
transfer_message: str | None = None
|
transfer_message: str | None = None
|
||||||
is_enabled: bool | None = None
|
is_enabled: bool | None = None
|
||||||
|
metadata_: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class IntentMatchResult:
|
class IntentMatchResult:
|
||||||
|
|
@ -643,6 +659,7 @@ class ScriptFlow(SQLModel, table=True):
|
||||||
"""
|
"""
|
||||||
[AC-AISVC-71] Script flow entity with tenant isolation.
|
[AC-AISVC-71] Script flow entity with tenant isolation.
|
||||||
Stores flow definition with steps in JSONB format.
|
Stores flow definition with steps in JSONB format.
|
||||||
|
[AC-IDSMETA-16] Extended with metadata field for unified storage structure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "script_flows"
|
__tablename__ = "script_flows"
|
||||||
|
|
@ -660,6 +677,11 @@ class ScriptFlow(SQLModel, table=True):
|
||||||
description="Flow steps list with step_no, content, wait_input, timeout_seconds"
|
description="Flow steps list with step_no, content, wait_input, timeout_seconds"
|
||||||
)
|
)
|
||||||
is_enabled: bool = Field(default=True, description="Whether the flow is enabled")
|
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")
|
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update 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)")
|
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):
|
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)")
|
step_no: int = Field(..., ge=1, description="Step number (1-indexed)")
|
||||||
content: str = Field(..., description="Script content for this step")
|
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")
|
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):
|
class ScriptFlowCreate(SQLModel):
|
||||||
"""[AC-AISVC-71] Schema for creating a new script flow."""
|
"""[AC-AISVC-71] Schema for creating a new script flow."""
|
||||||
|
|
@ -725,6 +782,7 @@ class ScriptFlowCreate(SQLModel):
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
steps: list[dict[str, Any]]
|
steps: list[dict[str, Any]]
|
||||||
is_enabled: bool = True
|
is_enabled: bool = True
|
||||||
|
metadata_: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ScriptFlowUpdate(SQLModel):
|
class ScriptFlowUpdate(SQLModel):
|
||||||
|
|
@ -734,6 +792,7 @@ class ScriptFlowUpdate(SQLModel):
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
steps: list[dict[str, Any]] | None = None
|
steps: list[dict[str, Any]] | None = None
|
||||||
is_enabled: bool | None = None
|
is_enabled: bool | None = None
|
||||||
|
metadata_: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class FlowAdvanceResult:
|
class FlowAdvanceResult:
|
||||||
|
|
@ -886,3 +945,271 @@ class ConversationDetail(SQLModel):
|
||||||
should_transfer: bool = False
|
should_transfer: bool = False
|
||||||
execution_steps: list[dict[str, Any]] | None = None
|
execution_steps: list[dict[str, Any]] | None = None
|
||||||
created_at: datetime
|
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