liuyun f1cb5723b2 feat: wire all management pages to backend (config/dict/org/resource/role/audit/login-log)
- 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>
2026-10-11 12:45:53 +08:00

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>