- 新增 dict store 与 useDict Hook(登录后缓存、登出清空) - ty-form/ty-table 支持 dictCode 自动解析下拉与标签 - 用户/角色/资源/组织/租户/审计/登录日志页去硬编码映射 - 字典管理页内置标记、value 保护、标签色编辑 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
10 KiB
Vue
336 lines
10 KiB
Vue
<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>
|