liuyun 23e228783c feat: dict-driven rendering — useDict + component dictCode
- 新增 dict store 与 useDict Hook(登录后缓存、登出清空)
- ty-form/ty-table 支持 dictCode 自动解析下拉与标签
- 用户/角色/资源/组织/租户/审计/登录日志页去硬编码映射
- 字典管理页内置标记、value 保护、标签色编辑

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 14:29:28 +08:00

336 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dict-page">
<el-card class="dict-page__types" shadow="never">
<div class="dict-page__type-head">
<el-input v-model="typeKeyword" placeholder="搜索字典类型" clearable class="min-w-0 flex-1" />
<el-button type="primary" @click="openTypeCreate">新增</el-button>
</div>
<div class="dict-page__type-list">
<el-scrollbar class="h-full">
<div
v-for="t in filteredTypes"
:key="t.code"
class="dict-page__type-item"
:class="{ 'is-active': t.code === selectedType?.code }"
@click="selectType(t)"
>
<div class="dict-page__type-item-row">
<span class="dict-page__type-item-name">
{{ t.name }}
<el-tag v-if="t.builtIn" size="small" type="primary" effect="plain">内置</el-tag>
</span>
<div class="dict-page__type-item-actions" @click.stop>
<el-button link type="primary" size="small" @click="openTypeEdit(t)">编辑</el-button>
<el-button v-if="!t.builtIn" link type="danger" size="small" @click="removeType(t)">删除</el-button>
</div>
</div>
<div class="dict-page__type-item-meta">
<span class="dict-page__type-item-code">{{ t.code }}</span>
<el-tag :type="commonStatusMap[t.status]?.type ?? 'info'" size="small">
{{ commonStatusMap[t.status]?.label ?? t.status }}
</el-tag>
</div>
</div>
<el-empty v-if="filteredTypes.length === 0" description="无匹配的字典类型" :image-size="60" />
</el-scrollbar>
</div>
</el-card>
<div class="dict-page__items">
<ty-crud
v-if="selectedType"
:key="selectedType.code"
ref="itemCrudRef"
entity-name="字典项"
:columns="itemColumns"
:search-schema="itemSearchSchema"
:form-schema="itemFormSchema"
:list="listItems"
:create="createItem"
:update="updateItem"
:remove="removeItem"
:actions="itemActions"
:get-edit-readonly-keys="(row) => (row.builtIn ? ['value'] : [])"
id-key="id"
name-key="label"
/>
<div v-else class="dict-page__items-empty">
<el-empty description="请选择或新增字典类型" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import { useDict } from '@/composables/useDict'
import {
createDictItem,
createDictType,
deleteDictItem,
deleteDictType,
listDictItems,
listDictTypes,
updateDictItem,
updateDictType,
type DictType,
} from '@/api/dict'
const { tagMap: commonStatusMap } = useDict('common_status')
/** 标签色中文名(UI 调色板,非业务字典)。 */
const COLOR_LABELS: Record<string, string> = {
primary: '主色',
success: '成功',
warning: '警告',
danger: '危险',
info: '信息',
}
// ---- 字典类型(左) ----
const { data: typePage, refresh: refreshTypes } = useRequest((signal: AbortSignal) => listDictTypes(1, 1000, signal))
const types = computed<DictType[]>(() => typePage.value?.list ?? [])
const typeKeyword = ref('')
const selectedType = ref<DictType | null>(null)
const filteredTypes = computed(() => {
const kw = typeKeyword.value.trim().toLowerCase()
if (!kw) return types.value
return types.value.filter((t) => t.name.toLowerCase().includes(kw) || t.code.toLowerCase().includes(kw))
})
// 类型加载后默认选中第一个;当前选中被删后回退到第一个
watch(types, (list) => {
if (!selectedType.value) selectedType.value = list[0] ?? null
else if (!list.some((t) => t.id === selectedType.value!.id)) selectedType.value = list[0] ?? null
})
function selectType(t: DictType): void {
selectedType.value = t
}
// ---- 字典类型表单 ----
const typeFormSchema: JsonSchema = {
type: 'object',
properties: {
code: { type: 'string', title: '字典编码', placeholder: '如 sys_status', minLength: 2 },
name: { type: 'string', title: '字典名称', placeholder: '如 系统状态' },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
showOn: ['edit'],
},
remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' },
},
required: ['code', 'name'],
}
function buildTypeModel(t?: DictType): Record<string, any> {
return { code: t?.code ?? '', name: t?.name ?? '', status: t?.status ?? 'enabled', remark: t?.remark ?? '' }
}
async function openTypeCreate(): Promise<void> {
const data = await showDialog({
component: TyForm,
props: { schema: typeFormSchema, modelValue: buildTypeModel(), mode: 'create' },
title: '新增字典类型',
width: '480px',
})
if (data == null) return
await createDictType({ code: data.code, name: data.name, remark: data.remark })
ElMessage.success('新增成功')
await refreshTypes()
selectedType.value = types.value.find((t) => t.code === data.code) ?? selectedType.value
}
async function openTypeEdit(t: DictType): Promise<void> {
const data = await showDialog({
component: TyForm,
props: { schema: typeFormSchema, modelValue: buildTypeModel(t), mode: 'edit', readonlyKeys: ['code'] },
title: '编辑字典类型',
width: '480px',
})
if (data == null) return
await updateDictType(t.id, { name: data.name, status: data.status, remark: data.remark })
ElMessage.success('更新成功')
await refreshTypes()
}
async function removeType(t: DictType): Promise<void> {
await ElMessageBox.confirm(`确认删除字典类型「${t.name}」?`, '提示', { type: 'warning' })
await deleteDictType(t.id)
ElMessage.success('删除成功')
await refreshTypes()
}
// ---- 字典项(右) ----
const itemColumns: TableColumn[] = [
{ prop: 'label', label: '标签', minWidth: 120 },
{ prop: 'value', label: '值', minWidth: 120 },
{ prop: 'colorType', label: '标签色', width: 90, align: 'center', formatter: (v) => (v ? COLOR_LABELS[String(v)] ?? String(v) : '—') },
{ prop: 'builtIn', label: '内置', width: 70, align: 'center', formatter: (v) => (v ? '内置' : '') },
{ prop: 'sort', label: '排序', width: 70, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', type: 'tag', dictCode: 'common_status' },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
const itemSearchSchema: JsonSchema = {
type: 'object',
properties: {
label: { type: 'string', title: '标签', placeholder: '请输入标签' },
value: { type: 'string', title: '值', placeholder: '请输入值' },
},
}
const itemFormSchema: JsonSchema = {
type: 'object',
properties: {
label: { type: 'string', title: '标签', placeholder: '如 启用' },
value: { type: 'string', title: '值', placeholder: '如 enabled' },
colorType: {
type: 'string',
title: '标签色',
enum: ['primary', 'success', 'warning', 'danger', 'info'],
enumNames: ['主色', '成功', '警告', '危险', '信息'],
placeholder: '默认灰',
},
sort: { type: 'integer', title: '排序', minimum: 0, default: 0 },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
showOn: ['edit'],
},
},
required: ['label', 'value'],
}
const listItems = async (query: PageQuery, signal?: AbortSignal) => {
const code = selectedType.value?.code
if (!code) return { list: [], total: 0, page: query.page, pageSize: query.pageSize }
const items = await listDictItems(code, signal)
const filtered = items.filter((i) => {
if (query.label && !i.label.includes(String(query.label))) return false
if (query.value && !i.value.includes(String(query.value))) return false
return true
})
const start = (query.page - 1) * query.pageSize
return {
list: filtered.slice(start, start + query.pageSize) as unknown as Record<string, any>[],
total: filtered.length,
page: query.page,
pageSize: query.pageSize,
}
}
const createItem = (data: Record<string, any>) =>
createDictItem({
dictTypeCode: selectedType.value?.code ?? '',
label: data.label,
value: data.value,
sort: data.sort ?? 0,
colorType: data.colorType || undefined,
})
const updateItem = (id: string, data: Record<string, any>) =>
updateDictItem(id, {
label: data.label,
value: data.value,
sort: data.sort ?? 0,
status: data.status,
colorType: data.colorType || undefined,
})
const removeItem = (id: string) => deleteDictItem(id)
const itemCrudRef = ref()
const itemActions: CrudAction[] = [
'edit',
{ label: '删除', type: 'danger', onClick: (row) => removeWithConfirm(row), show: (row) => !row.builtIn },
]
async function removeWithConfirm(row: Record<string, any>): Promise<void> {
await ElMessageBox.confirm(`确认删除字典项「${row.label}」?`, '提示', { type: 'warning' })
await deleteDictItem(String(row.id))
ElMessage.success('删除成功')
itemCrudRef.value?.reload()
}
</script>
<style scoped lang="scss">
.dict-page {
@apply flex h-full gap-md;
}
.dict-page__types {
@apply w-[280px] flex-shrink-0;
:deep(.el-card__body) {
@apply flex h-full flex-col;
}
}
.dict-page__type-head {
@apply mb-sm flex flex-shrink-0 gap-sm;
}
.dict-page__type-list {
@apply min-h-0 flex-1;
}
.dict-page__type-item {
@apply mb-xs cursor-pointer rounded-sm border border-transparent px-sm py-xs transition-colors;
&:hover {
@apply bg-bg-page;
}
&.is-active {
@apply border-primary bg-primary-light-7;
}
}
.dict-page__type-item-row {
@apply flex items-center justify-between;
}
.dict-page__type-item-name {
@apply truncate font-medium text-text-primary;
}
.dict-page__type-item-actions {
@apply hidden flex-shrink-0;
}
.dict-page__type-item:hover .dict-page__type-item-actions {
@apply flex items-center gap-xs;
}
.dict-page__type-item-meta {
@apply mt-xs flex items-center justify-between;
}
.dict-page__type-item-code {
@apply truncate text-xs text-text-secondary;
}
.dict-page__items {
@apply min-w-0 flex-1;
}
.dict-page__items-empty {
@apply flex h-full items-center justify-center;
}
</style>