- 401 自动登出:http 会话过期回调 + main.ts 注册(清 menu/tabs + 跳登录) - 参数配置/字典/组织/资源/角色/操作日志/登录日志对接真实接口 - 角色管理新增权限分配树(el-tree 勾选资源,/roles/:code/resources) - 操作日志/登录日志只读页(ty-search-form + ty-table + useRequest) - listOrgs 迁到 api/org.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
295 lines
9.0 KiB
Vue
295 lines
9.0 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 }}</span>
|
|
<div class="dict-page__type-item-actions" @click.stop>
|
|
<el-button link type="primary" size="small" @click="openTypeEdit(t)">编辑</el-button>
|
|
<el-button 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="t.status === 'enabled' ? 'success' : 'info'" size="small">
|
|
{{ t.status === 'enabled' ? '启用' : '停用' }}
|
|
</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"
|
|
entity-name="字典项"
|
|
:columns="itemColumns"
|
|
:search-schema="itemSearchSchema"
|
|
:form-schema="itemFormSchema"
|
|
:list="listItems"
|
|
:create="createItem"
|
|
:update="updateItem"
|
|
:remove="removeItem"
|
|
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 { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
|
import TyForm from '@/components/ty-form.vue'
|
|
import { showDialog } from '@/utils/dialog'
|
|
import { useRequest } from '@/composables/useRequest'
|
|
import {
|
|
createDictItem,
|
|
createDictType,
|
|
deleteDictItem,
|
|
deleteDictType,
|
|
listDictItems,
|
|
listDictTypes,
|
|
updateDictItem,
|
|
updateDictType,
|
|
type DictType,
|
|
} from '@/api/dict'
|
|
|
|
const STATUS_MAP = {
|
|
enabled: { label: '启用', type: 'success' },
|
|
disabled: { label: '停用', type: 'info' },
|
|
} as const
|
|
|
|
// ---- 字典类型(左) ----
|
|
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: '状态',
|
|
enum: ['enabled', 'disabled'],
|
|
enumNames: ['启用', '停用'],
|
|
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: 'sort', label: '排序', width: 80, align: 'center' },
|
|
{ prop: 'status', label: '状态', width: 90, align: 'center', type: 'tag', tagMap: STATUS_MAP },
|
|
{ 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' },
|
|
sort: { type: 'integer', title: '排序', minimum: 0, default: 0 },
|
|
status: {
|
|
type: 'string',
|
|
title: '状态',
|
|
enum: ['enabled', 'disabled'],
|
|
enumNames: ['启用', '停用'],
|
|
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,
|
|
})
|
|
|
|
const updateItem = (id: string, data: Record<string, any>) =>
|
|
updateDictItem(id, { label: data.label, value: data.value, sort: data.sort ?? 0, status: data.status })
|
|
|
|
const removeItem = (id: string) => deleteDictItem(id)
|
|
</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>
|