新增分段管理
This commit is contained in:
@@ -568,6 +568,11 @@ export const reflectionApi = {
|
||||
? Promise.resolve<CarStyleTypesPayload>({ globalDefault: defaultCarStyleDto(), types: [] })
|
||||
: get<CarStyleTypesPayload>('/car-style/types'),
|
||||
|
||||
/** 车型编码字段元数据(site/track/plan/car 四类字段及默认值)。 */
|
||||
getCarTypeCoderFields: () => MOCK
|
||||
? Promise.resolve<CarTypeCoderFieldsRow[]>([])
|
||||
: get<CarTypeCoderFieldsRow[]>('/car-types/coder-fields'),
|
||||
|
||||
getCarStyle: (typeFullName: string) => MOCK
|
||||
? Promise.resolve(defaultCarStyleDto())
|
||||
: get<CarStyleDto>(`/car-style/${encodeURIComponent(typeFullName)}`),
|
||||
@@ -741,6 +746,31 @@ export interface CarStyleTypesPayload {
|
||||
types: CarStyleTypeRow[]
|
||||
}
|
||||
|
||||
export interface CoderFieldDef {
|
||||
name: string
|
||||
typeName: string
|
||||
defaultValue: unknown
|
||||
}
|
||||
|
||||
export interface CoderFieldGroup {
|
||||
typeName: string
|
||||
shortName: string
|
||||
assemblyName: string
|
||||
baseTypeName?: string
|
||||
fields: CoderFieldDef[]
|
||||
}
|
||||
|
||||
export interface CarTypeCoderFieldsRow {
|
||||
typeName: string
|
||||
shortName: string
|
||||
label: string
|
||||
assemblyName: string
|
||||
siteFields: CoderFieldGroup
|
||||
trackFields: CoderFieldGroup
|
||||
planFields: CoderFieldGroup
|
||||
carFields: CoderFieldGroup
|
||||
}
|
||||
|
||||
export interface AlarmColorEntry {
|
||||
key: string
|
||||
colorArgb: number
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import http from './http'
|
||||
import type { SimpleFieldBatchPayload, SimpleFieldPayload, SimpleFieldRecord } from '@/types/simpleField'
|
||||
|
||||
function q(params?: Record<string, unknown>) {
|
||||
return { params }
|
||||
}
|
||||
|
||||
export async function listSimpleFields(fieldType?: string, carType?: string, keyword?: string) {
|
||||
const { data } = await http.get<SimpleFieldRecord[]>('/simple-fields', q({
|
||||
fieldType,
|
||||
carType,
|
||||
q: keyword
|
||||
}))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveSimpleField(payload: SimpleFieldPayload) {
|
||||
const { data } = payload.id
|
||||
? await http.put<SimpleFieldRecord>(`/simple-fields/${payload.id}`, payload)
|
||||
: await http.post<SimpleFieldRecord>('/simple-fields', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteSimpleField(id: string) {
|
||||
await http.delete(`/simple-fields/${id}`)
|
||||
}
|
||||
|
||||
export async function saveSimpleFieldsBatch(payload: SimpleFieldBatchPayload) {
|
||||
const { data } = await http.post<{ count: number }>('/simple-fields/batch', payload)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<el-card class="dtp-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="dtp-header">
|
||||
<span class="dtp-title">{{ title }}</span>
|
||||
<div class="dtp-actions">
|
||||
<el-input v-if="searchable" v-model="kw" :placeholder="searchPlaceholder" clearable size="small" style="width: 220px" />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="filtered" stripe size="small" :max-height="maxHeight" border>
|
||||
<el-table-column v-for="c in columns" :key="c.prop" :prop="c.prop" :label="c.label" :width="c.width" :min-width="c.minWidth">
|
||||
<template #default="scope">
|
||||
<slot :name="`col-${c.prop}`" :row="scope.row">
|
||||
{{ scope.row[c.prop] }}
|
||||
</slot>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<slot name="extra-columns" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Column { prop: string; label: string; width?: number | string; minWidth?: number | string }
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
data: Array<Record<string, unknown>>
|
||||
columns: Column[]
|
||||
searchable?: boolean
|
||||
searchPlaceholder?: string
|
||||
maxHeight?: number | string
|
||||
}>()
|
||||
|
||||
const kw = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!props.searchable || !kw.value) return props.data
|
||||
const q = kw.value.trim().toLowerCase()
|
||||
return props.data.filter((row) =>
|
||||
Object.values(row).some((v) => String(v ?? '').toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dtp-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.dtp-title { font-weight: 600; }
|
||||
.dtp-actions { display: flex; gap: 8px; align-items: center; }
|
||||
</style>
|
||||
@@ -154,7 +154,8 @@ const ADMIN_MENU: MenuItem[] = [
|
||||
{ path: '/admin/cars', label: '车辆管理', key: 'admin-cars' },
|
||||
{ path: '/admin/processes', label: '进程管理', key: 'admin-processes' },
|
||||
{ path: '/admin/scripts', label: '脚本管理', key: 'admin-scripts' },
|
||||
{ path: '/admin/task-templates', label: '任务编排', key: 'admin-task-templates' }
|
||||
{ path: '/admin/task-templates', label: '任务编排', key: 'admin-task-templates' },
|
||||
{ path: '/admin/simple-fields', label: '字段管理', key: 'admin-simple-fields' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ const PAGES: PageDef[] = [
|
||||
{ key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-facility', label: '设备与库位', group: '平台配置中心', scope: 'Platform' },
|
||||
|
||||
@@ -37,6 +37,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } },
|
||||
{ path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } },
|
||||
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
||||
{ path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
||||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||||
// ── 平台配置中心:聚合页(每个聚合页一个 page key = route.name,对齐后端 PageCatalog)。
|
||||
// 原十余个独立配置页按业务收敛为下列 6 个入口,子页改为聚合页内的 tab。 ──
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
export type SimpleFieldCategory = 'siteFields' | 'trackFields' | 'planFields' | 'carFields'
|
||||
|
||||
export const SIMPLE_FIELD_CATEGORIES: { key: SimpleFieldCategory; label: string }[] = [
|
||||
{ key: 'siteFields', label: '站点字段 (siteFields)' },
|
||||
{ key: 'trackFields', label: '路径字段 (trackFields)' },
|
||||
{ key: 'planFields', label: '计划字段 (planFields)' },
|
||||
{ key: 'carFields', label: '车辆字段 (carFields)' }
|
||||
]
|
||||
|
||||
export interface SimpleFieldRecord {
|
||||
id: string
|
||||
carType: string
|
||||
fieldType: string
|
||||
key: string
|
||||
value: string
|
||||
dataType: string
|
||||
chinese: string | null
|
||||
english: string | null
|
||||
other: string
|
||||
isDefault: boolean
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface SimpleFieldPayload {
|
||||
id?: string
|
||||
carType: string
|
||||
fieldType: string
|
||||
key: string
|
||||
value?: string
|
||||
dataType?: string
|
||||
chinese?: string | null
|
||||
english?: string | null
|
||||
other?: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface CoderFieldDef {
|
||||
name: string
|
||||
typeName: string
|
||||
defaultValue: unknown
|
||||
}
|
||||
|
||||
export interface CoderFieldGroup {
|
||||
typeName: string
|
||||
shortName: string
|
||||
assemblyName: string
|
||||
baseTypeName?: string
|
||||
fields: CoderFieldDef[]
|
||||
}
|
||||
|
||||
export interface CarTypeCoderFields {
|
||||
typeName: string
|
||||
shortName: string
|
||||
label: string
|
||||
assemblyName: string
|
||||
siteFields: CoderFieldGroup
|
||||
trackFields: CoderFieldGroup
|
||||
planFields: CoderFieldGroup
|
||||
carFields: CoderFieldGroup
|
||||
}
|
||||
|
||||
export interface SimpleFieldBatchPayload {
|
||||
replaceAll: boolean
|
||||
items: SimpleFieldPayload[]
|
||||
}
|
||||
|
||||
export interface FieldRow {
|
||||
rowKey: string
|
||||
id?: string
|
||||
carType: string
|
||||
fieldType: SimpleFieldCategory
|
||||
key: string
|
||||
dataType: string
|
||||
value: string
|
||||
chinese: string | null
|
||||
english: string | null
|
||||
other: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export const ALL_CAR_TYPES = '__all__'
|
||||
|
||||
/** car_type 唯一标识:assemblyName.shortName */
|
||||
export function buildCarType(assemblyName: string, shortName: string): string {
|
||||
const asm = assemblyName?.trim() ?? ''
|
||||
const sn = shortName?.trim() ?? ''
|
||||
return asm && sn ? `${asm}.${sn}` : sn || asm
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
<template>
|
||||
<div class="simple-field-page">
|
||||
<el-card v-loading="initializing" shadow="never" class="page-card" element-loading-text="正在加载字段数据…">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>字段管理</h2>
|
||||
<p>「默认」从 SimpleLite 拉取全部车型字段;「刷新」从数据库读取;「保存」将全部字段写入数据库。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :loading="initializing || loadingDefaults" @click="loadDefaults">默认</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="initializing || !fieldRows.length" @click="saveAll">保存</el-button>
|
||||
<el-button :icon="Refresh" :loading="initializing || loading" @click="loadFromDb()">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filters">
|
||||
<div class="car-type-filter">
|
||||
<span class="filter-label">车辆类型:</span>
|
||||
<el-select
|
||||
v-model="filterCarType"
|
||||
filterable
|
||||
placeholder="请选择车型"
|
||||
class="car-type-select bordered-select"
|
||||
popper-class="car-type-option-popper"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in carTypes"
|
||||
:key="c.typeName"
|
||||
:label="carTypeSelectLabel(c)"
|
||||
:value="buildCarType(c.assemblyName, c.shortName)"
|
||||
>
|
||||
<span class="car-type-option">
|
||||
<span class="car-type-option-cn">{{ carTypeCn(c) }}</span>
|
||||
<span class="car-type-option-en">({{ buildCarType(c.assemblyName, c.shortName) }})</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="keyword-filter">
|
||||
<span class="filter-label">搜索:</span>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="当前车型下搜索属性 / key / 中文 / 英文 / 其他语言"
|
||||
clearable
|
||||
class="keyword-input"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" :disabled="initializing" @click="openDialog()">新增字段</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeCategory" class="field-tabs">
|
||||
<el-tab-pane
|
||||
v-for="cat in SIMPLE_FIELD_CATEGORIES"
|
||||
:key="cat.key"
|
||||
:name="cat.key"
|
||||
:label="cat.label"
|
||||
>
|
||||
<el-table
|
||||
v-loading="initializing || loading || loadingDefaults"
|
||||
:data="filteredRows"
|
||||
:row-class-name="searchRowClassName"
|
||||
border
|
||||
size="small"
|
||||
height="520"
|
||||
>
|
||||
<el-table-column prop="carType" label="车型 (car_type)" min-width="200" />
|
||||
<el-table-column prop="key" label="属性" min-width="140" sortable />
|
||||
<el-table-column prop="dataType" label="数据类型" min-width="130" sortable />
|
||||
<el-table-column prop="value" label="默认值" min-width="100" />
|
||||
<el-table-column prop="chinese" label="中文名" min-width="100" />
|
||||
<el-table-column prop="english" label="英文名" min-width="100" />
|
||||
<el-table-column prop="other" label="其他语言" min-width="100" />
|
||||
<el-table-column prop="isDefault" label="默认" width="72" sortable :sort-method="sortByIsDefault">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isDefault ? 'info' : 'success'" size="small">{{ row.isDefault ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="removeRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="车型" required>
|
||||
<el-select
|
||||
v-model="form.carType"
|
||||
filterable
|
||||
:disabled="!!editingRowKey"
|
||||
class="bordered-select"
|
||||
popper-class="car-type-option-popper"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in carTypes"
|
||||
:key="c.typeName"
|
||||
:label="carTypeSelectLabel(c)"
|
||||
:value="buildCarType(c.assemblyName, c.shortName)"
|
||||
>
|
||||
<span class="car-type-option">
|
||||
<span class="car-type-option-cn">{{ carTypeCn(c) }}</span>
|
||||
<span class="car-type-option-en">({{ buildCarType(c.assemblyName, c.shortName) }})</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="字段类型" required>
|
||||
<el-select
|
||||
v-model="form.fieldType"
|
||||
:disabled="!!editingRowKey"
|
||||
class="bordered-select"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="cat in SIMPLE_FIELD_CATEGORIES"
|
||||
:key="cat.key"
|
||||
:label="cat.label"
|
||||
:value="cat.key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="属性" required>
|
||||
<el-input v-model="form.key" :disabled="!!editingRowKey" placeholder="属性名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数据类型" required>
|
||||
<el-select
|
||||
v-model="form.dataType"
|
||||
filterable
|
||||
allow-create
|
||||
:disabled="!!editingRowKey"
|
||||
class="bordered-select"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="t in DATA_TYPE_OPTIONS" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认值" required>
|
||||
<el-input v-model="form.value" placeholder="默认值(字符串存储)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="中文名">
|
||||
<el-input v-model="form.chinese" />
|
||||
</el-form-item>
|
||||
<el-form-item label="英文名">
|
||||
<el-input v-model="form.english" />
|
||||
</el-form-item>
|
||||
<el-form-item label="其他语言">
|
||||
<el-input v-model="form.other" placeholder="日语、德语等其他语言名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingRowKey" label="是否内置">
|
||||
<el-switch v-model="form.isDefault" class="builtin-switch" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="applyDialog">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
|
||||
import * as simpleFieldApi from '@/api/simpleField'
|
||||
import {
|
||||
SIMPLE_FIELD_CATEGORIES,
|
||||
buildCarType,
|
||||
type FieldRow,
|
||||
type SimpleFieldCategory,
|
||||
type SimpleFieldRecord
|
||||
} from '@/types/simpleField'
|
||||
|
||||
const DATA_TYPE_OPTIONS = [
|
||||
'System.Boolean',
|
||||
'System.Int32',
|
||||
'System.Single',
|
||||
'System.Double',
|
||||
'System.String'
|
||||
]
|
||||
|
||||
const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const loadingDefaults = ref(false)
|
||||
const saving = ref(false)
|
||||
const carTypes = ref<ReflectionCreatableType[]>([])
|
||||
const fieldRows = ref<FieldRow[]>([])
|
||||
const filterCarType = ref('')
|
||||
const activeCategory = ref<SimpleFieldCategory>('siteFields')
|
||||
const keyword = ref('')
|
||||
const highlightRowKey = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingRowKey = ref('')
|
||||
|
||||
const form = reactive({
|
||||
carType: '',
|
||||
fieldType: 'siteFields' as SimpleFieldCategory,
|
||||
key: '',
|
||||
value: '',
|
||||
dataType: 'System.String',
|
||||
chinese: '',
|
||||
english: '',
|
||||
other: '',
|
||||
isDefault: false
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => (editingRowKey.value ? '编辑字段' : '新增字段'))
|
||||
|
||||
const categoryRows = computed(() =>
|
||||
rowsForCurrentCar().filter((r) => r.fieldType === activeCategory.value)
|
||||
)
|
||||
|
||||
function rowsForCurrentCar() {
|
||||
if (!filterCarType.value) return fieldRows.value
|
||||
return fieldRows.value.filter((r) => r.carType === filterCarType.value)
|
||||
}
|
||||
|
||||
function rowMatchesKeyword(row: FieldRow, kw: string) {
|
||||
return (
|
||||
row.key.toLowerCase().includes(kw) ||
|
||||
(row.chinese ?? '').toLowerCase().includes(kw) ||
|
||||
(row.english ?? '').toLowerCase().includes(kw) ||
|
||||
row.other.toLowerCase().includes(kw) ||
|
||||
row.value.toLowerCase().includes(kw) ||
|
||||
row.dataType.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
function findFirstSearchMatch(kw: string): FieldRow | undefined {
|
||||
const rows = rowsForCurrentCar()
|
||||
for (const cat of SIMPLE_FIELD_CATEGORIES) {
|
||||
const match = rows.find((r) => r.fieldType === cat.key && rowMatchesKeyword(r, kw))
|
||||
if (match) return match
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function searchRowClassName({ row }: { row: FieldRow }) {
|
||||
return row.rowKey === highlightRowKey.value ? 'search-hit-row' : ''
|
||||
}
|
||||
|
||||
function sortByIsDefault(a: FieldRow, b: FieldRow) {
|
||||
return Number(a.isDefault) - Number(b.isDefault)
|
||||
}
|
||||
|
||||
function scrollToHighlightedRow() {
|
||||
nextTick(() => {
|
||||
document.querySelector('.field-tabs .search-hit-row')?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
function navigateSearchToFirstMatch() {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
highlightRowKey.value = ''
|
||||
if (!kw) return
|
||||
const first = findFirstSearchMatch(kw)
|
||||
if (!first) return
|
||||
activeCategory.value = first.fieldType
|
||||
highlightRowKey.value = first.rowKey
|
||||
scrollToHighlightedRow()
|
||||
}
|
||||
|
||||
watch([keyword, filterCarType], () => {
|
||||
navigateSearchToFirstMatch()
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const rows = categoryRows.value
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return rows
|
||||
return rows.filter((r) => rowMatchesKeyword(r, kw))
|
||||
})
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
if (typeof v === 'object') return JSON.stringify(v)
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function makeRowKey(carType: string, fieldType: string, key: string) {
|
||||
return `${carType}:${fieldType}:${key}`
|
||||
}
|
||||
|
||||
function recordToRow(r: SimpleFieldRecord): FieldRow {
|
||||
return {
|
||||
rowKey: makeRowKey(r.carType, r.fieldType, r.key),
|
||||
id: r.id,
|
||||
carType: r.carType,
|
||||
fieldType: r.fieldType as SimpleFieldCategory,
|
||||
key: r.key,
|
||||
dataType: r.dataType,
|
||||
value: r.value,
|
||||
chinese: r.chinese,
|
||||
english: r.english,
|
||||
other: r.other,
|
||||
isDefault: r.isDefault
|
||||
}
|
||||
}
|
||||
|
||||
function carTypeToRows(car: CarTypeCoderFieldsRow): FieldRow[] {
|
||||
const carType = buildCarType(car.assemblyName, car.shortName)
|
||||
const rows: FieldRow[] = []
|
||||
for (const cat of SIMPLE_FIELD_CATEGORIES) {
|
||||
const group = car[cat.key]
|
||||
for (const f of group?.fields ?? []) {
|
||||
rows.push({
|
||||
rowKey: makeRowKey(carType, cat.key, f.name),
|
||||
carType,
|
||||
fieldType: cat.key,
|
||||
key: f.name,
|
||||
dataType: f.typeName,
|
||||
value: formatValue(f.defaultValue),
|
||||
chinese: '',
|
||||
english: '',
|
||||
other: '',
|
||||
isDefault: true
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function allCarsToRows(cars: CarTypeCoderFieldsRow[]): FieldRow[] {
|
||||
return cars.flatMap(carTypeToRows)
|
||||
}
|
||||
|
||||
/** 从已加载的数据库记录推导车型下拉(不请求 SimpleLite)。 */
|
||||
function syncCarTypesFromFieldRows() {
|
||||
const labelByKey = new Map(
|
||||
carTypes.value.map((c) => [buildCarType(c.assemblyName, c.shortName), c.label])
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
const options: ReflectionCreatableType[] = []
|
||||
for (const r of fieldRows.value) {
|
||||
if (seen.has(r.carType)) continue
|
||||
seen.add(r.carType)
|
||||
const carType = r.carType
|
||||
const dot = carType.lastIndexOf('.')
|
||||
const assemblyName = dot >= 0 ? carType.slice(0, dot) : ''
|
||||
const shortName = dot >= 0 ? carType.slice(dot + 1) : carType
|
||||
options.push({
|
||||
typeName: carType,
|
||||
shortName,
|
||||
label: labelByKey.get(carType) || shortName,
|
||||
assemblyName
|
||||
})
|
||||
}
|
||||
carTypes.value = options
|
||||
}
|
||||
|
||||
/** 从 SimpleLite 补全车型中文名(轻量接口,不拉字段定义)。 */
|
||||
async function enrichCarTypeLabels() {
|
||||
try {
|
||||
const types = await reflectionApi.listCreatableTypes('car')
|
||||
const labelMap = new Map(
|
||||
types.map((t) => [buildCarType(t.assemblyName, t.shortName), t.label || t.shortName])
|
||||
)
|
||||
carTypes.value = carTypes.value.map((c) => {
|
||||
const key = buildCarType(c.assemblyName, c.shortName)
|
||||
const label = labelMap.get(key)
|
||||
return label ? { ...c, label } : c
|
||||
})
|
||||
} catch {
|
||||
/* SimpleLite 未连接时保留现有显示 */
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDefaultCarType() {
|
||||
if (filterCarType.value) return
|
||||
if (carTypes.value.length) {
|
||||
const first = carTypes.value[0]
|
||||
filterCarType.value = buildCarType(first.assemblyName, first.shortName)
|
||||
} else if (fieldRows.value.length) {
|
||||
filterCarType.value = fieldRows.value[0].carType
|
||||
}
|
||||
}
|
||||
|
||||
function carTypeCn(c: ReflectionCreatableType) {
|
||||
if (c.label && c.label !== c.shortName) return c.label
|
||||
return c.label || c.shortName
|
||||
}
|
||||
|
||||
function carTypeSelectLabel(c: ReflectionCreatableType, fullCarType = true) {
|
||||
const en = fullCarType ? buildCarType(c.assemblyName, c.shortName) : c.shortName
|
||||
return `${carTypeCn(c)} (${en})`
|
||||
}
|
||||
|
||||
async function loadFromDb(silent = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
const records = await simpleFieldApi.listSimpleFields()
|
||||
fieldRows.value = records.map(recordToRow)
|
||||
syncCarTypesFromFieldRows()
|
||||
await enrichCarTypeLabels()
|
||||
ensureDefaultCarType()
|
||||
if (!silent) ElMessage.success(`已从数据库加载 ${fieldRows.value.length} 条字段`)
|
||||
} catch (e) {
|
||||
fieldRows.value = []
|
||||
carTypes.value = []
|
||||
ElMessage.error(e instanceof Error ? e.message : '从数据库加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
loadingDefaults.value = true
|
||||
try {
|
||||
const cars = await reflectionApi.getCarTypeCoderFields()
|
||||
if (!cars.length) {
|
||||
ElMessage.warning('未获取到车型列表,请确认 SimpleLite 已启动')
|
||||
return
|
||||
}
|
||||
carTypes.value = cars.map((c) => ({
|
||||
typeName: c.typeName,
|
||||
shortName: c.shortName,
|
||||
label: c.label,
|
||||
assemblyName: c.assemblyName
|
||||
}))
|
||||
ensureDefaultCarType()
|
||||
fieldRows.value = allCarsToRows(cars)
|
||||
ElMessage.success(`已从 SimpleLite 加载 ${cars.length} 种车型、共 ${fieldRows.value.length} 条默认字段(请点击保存写入数据库)`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '加载默认字段失败')
|
||||
} finally {
|
||||
loadingDefaults.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if (!fieldRows.value.length) return
|
||||
saving.value = true
|
||||
try {
|
||||
const { count } = await simpleFieldApi.saveSimpleFieldsBatch({
|
||||
replaceAll: true,
|
||||
items: fieldRows.value.map((r) => ({
|
||||
carType: r.carType,
|
||||
fieldType: r.fieldType,
|
||||
key: r.key,
|
||||
value: r.value,
|
||||
dataType: r.dataType,
|
||||
chinese: r.chinese ?? '',
|
||||
english: r.english ?? '',
|
||||
other: r.other,
|
||||
isDefault: r.isDefault
|
||||
}))
|
||||
})
|
||||
ElMessage.success(`已保存 ${count} 条字段到数据库`)
|
||||
await loadFromDb(true)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingRowKey.value = ''
|
||||
form.carType = filterCarType.value
|
||||
|| (carTypes.value[0] ? buildCarType(carTypes.value[0].assemblyName, carTypes.value[0].shortName) : '')
|
||||
form.fieldType = activeCategory.value
|
||||
form.key = ''
|
||||
form.value = ''
|
||||
form.dataType = 'System.String'
|
||||
form.chinese = ''
|
||||
form.english = ''
|
||||
form.other = ''
|
||||
form.isDefault = false
|
||||
}
|
||||
|
||||
function openDialog(row?: FieldRow) {
|
||||
resetForm()
|
||||
if (row) {
|
||||
editingRowKey.value = row.rowKey
|
||||
form.carType = row.carType
|
||||
form.fieldType = row.fieldType
|
||||
form.key = row.key
|
||||
form.value = row.value
|
||||
form.dataType = row.dataType
|
||||
form.chinese = row.chinese ?? ''
|
||||
form.english = row.english ?? ''
|
||||
form.other = row.other
|
||||
form.isDefault = row.isDefault
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function applyDialog() {
|
||||
if (!form.carType.trim()) {
|
||||
ElMessage.warning('请选择车型')
|
||||
return
|
||||
}
|
||||
if (!form.key.trim()) {
|
||||
ElMessage.warning('请填写属性')
|
||||
return
|
||||
}
|
||||
if (!form.dataType.trim()) {
|
||||
ElMessage.warning('请选择数据类型')
|
||||
return
|
||||
}
|
||||
if (!form.value.trim()) {
|
||||
ElMessage.warning('请填写默认值')
|
||||
return
|
||||
}
|
||||
const rowKey = makeRowKey(form.carType.trim(), form.fieldType, form.key.trim())
|
||||
const payload: FieldRow = {
|
||||
rowKey,
|
||||
carType: form.carType.trim(),
|
||||
fieldType: form.fieldType,
|
||||
key: form.key.trim(),
|
||||
dataType: form.dataType,
|
||||
value: form.value,
|
||||
chinese: form.chinese,
|
||||
english: form.english,
|
||||
other: form.other,
|
||||
isDefault: editingRowKey.value ? form.isDefault : false,
|
||||
}
|
||||
if (editingRowKey.value) {
|
||||
const idx = fieldRows.value.findIndex((r) => r.rowKey === editingRowKey.value)
|
||||
if (idx >= 0) {
|
||||
fieldRows.value[idx] = {
|
||||
...fieldRows.value[idx],
|
||||
value: form.value,
|
||||
chinese: form.chinese,
|
||||
english: form.english,
|
||||
other: form.other,
|
||||
isDefault: form.isDefault
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (fieldRows.value.some((r) => r.rowKey === rowKey)) {
|
||||
ElMessage.warning('该车型下该字段已存在')
|
||||
return
|
||||
}
|
||||
fieldRows.value.push(payload)
|
||||
}
|
||||
activeCategory.value = form.fieldType
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
async function removeRow(row: FieldRow) {
|
||||
await ElMessageBox.confirm(`确定删除字段「${row.carType} / ${row.key}」?`, '确认', { type: 'warning' })
|
||||
fieldRows.value = fieldRows.value.filter((r) => r.rowKey !== row.rowKey)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initializing.value = true
|
||||
try {
|
||||
await loadFromDb(true)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '初始化失败')
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-field-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.page-card { min-height: calc(100vh - 88px); }
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-header h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
.page-header p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.car-type-filter,
|
||||
.keyword-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.filter-label {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.car-type-select {
|
||||
width: 480px;
|
||||
}
|
||||
.car-type-select :deep(.el-select__selected-item),
|
||||
.car-type-select :deep(.el-select__placeholder) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keyword-input {
|
||||
width: 400px;
|
||||
}
|
||||
.bordered-select :deep(.el-select__wrapper) {
|
||||
min-height: 32px;
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #dcdfe6 !important;
|
||||
border-radius: var(--el-border-radius-base, 4px);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.bordered-select :deep(.el-select__wrapper:hover) {
|
||||
border-color: #c0c4cc !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.bordered-select :deep(.el-select__wrapper.is-focused),
|
||||
.bordered-select :deep(.el-select__wrapper.is-hovering.is-focused) {
|
||||
border-color: var(--el-color-primary, #7c3aed) !important;
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary, #7c3aed) inset !important;
|
||||
}
|
||||
.bordered-select :deep(.el-select__selected-item),
|
||||
.bordered-select :deep(.el-select__placeholder) {
|
||||
line-height: 30px;
|
||||
}
|
||||
.field-tabs { margin-top: 4px; }
|
||||
.field-tabs :deep(.search-hit-row > td.el-table__cell) {
|
||||
background-color: #f5f3ff !important;
|
||||
}
|
||||
.builtin-switch :deep(.el-switch__core) {
|
||||
border: 1px solid #c0c4cc;
|
||||
background-color: #dcdfe6 !important;
|
||||
}
|
||||
.builtin-switch.is-checked :deep(.el-switch__core) {
|
||||
background-color: var(--el-color-primary, #7c3aed) !important;
|
||||
border-color: var(--el-color-primary, #7c3aed) !important;
|
||||
}
|
||||
.builtin-switch :deep(.el-switch__action) {
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.car-type-option-popper {
|
||||
min-width: 520px !important;
|
||||
}
|
||||
.car-type-option-popper .car-type-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.car-type-option-popper .car-type-option-cn {
|
||||
flex: 0 0 auto;
|
||||
max-width: 11em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.car-type-option-popper .car-type-option-en {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user