Compare commits

..

6 Commits

Author SHA1 Message Date
Leo
75560e0c4b docs(dict): 添加字典使用指南
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-27 01:42:41 +08:00
Leo
5355993a2a feat(dict): enforce dictionary driven state on dictionary pages 2025-09-27 01:05:05 +08:00
Leo
862ed051ed feat(dict): reuse user dictionaries in personal center 2025-09-27 01:03:40 +08:00
Leo
05b0bc1376 feat(dict): align file and picture modules with dictionary services 2025-09-27 00:59:44 +08:00
Leo
7c1bf63133 feat(dict): apply dynamic dictionaries across user, menu and log pages 2025-09-27 00:54:38 +08:00
Leo
d50611c05b feat(dict): add shared dictionary utilities and components 2025-09-27 00:51:44 +08:00
17 changed files with 597 additions and 368 deletions

View File

@ -59,6 +59,14 @@ pnpm sizecheck
- **UnoCSS 66.2.0** 原子化CSS
- **Alova 3.3.2** HTTP客户端
## 开发规范补充
### 字典数据使用约定
- **严禁写死常量**:凡是来源于数据库的枚举/状态类数据(启用/停用、成功/失败、性别、角色/菜单/文件服务等),必须通过后端字典接口 `listDataByType` 获取,不得在前端硬编码。
- **统一入口**:所有页面应使用 `useDict`/`useDictStore`、`DictTag` 与 `getSelectOptions` 等工具消费字典,确保新增字典值时页面自动生效。
- **缓存刷新**:对字典类型或字典数据进行增删改操作后,务必调用 `dictStore.invalidate` 失效前端缓存,确保其它模块能够获取最新字典信息。
- **新增字典类型**:若发现现有字典无法覆盖业务,需要先在后端补充字典类型及数据,再在前端按照上述方式接入,不得继续使用手工数组。
### 目录结构要点
```
src/
@ -167,7 +175,7 @@ src/
## ⚠️ 严格禁止事项
**1. 消息提示**
```typescript
```text
// ❌ 严禁使用Element Plus原生消息
// ✅ 必须使用项目封装的消息函数
import { coiMsgError, coiMsgSuccess } from '@/utils/coi.ts'
@ -179,7 +187,7 @@ coiMsgError('操作失败')
```
**2. 类型定义**
```typescript
```text
// ❌ 严禁使用any类型
const response: any = await getList()
@ -188,7 +196,7 @@ const response: Result<ResponseVo[]> = await getList()
```
**3. 数据访问**
```typescript
```text
// ❌ 错误的数据访问
const data = response
@ -1229,74 +1237,10 @@ import { coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
### 标准按钮实现规范
**✅ 正确的表格操作列按钮实现方式**
```typescript
// 表格列定义 - 操作列
{
title: '操作',
key: 'actions',
width: 280,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
// 编辑按钮 - 主要操作按钮
if (hasPermission('edit')) {
buttons.push(h(NButton, {
type: 'primary',
size: 'small',
class: 'action-btn-primary',
onClick: () => handleEdit(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineEdit)
}),
default: () => '编辑',
}))
}
// 删除按钮 - 危险操作按钮
if (hasPermission('delete')) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.id),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => '确定删除此记录吗?',
trigger: () => h(NButton, {
type: 'error',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-danger',
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineDelete),
}),
default: () => '删除',
}),
}))
}
// 其他功能按钮 - 辅助操作按钮
if (hasPermission('other')) {
buttons.push(h(NButton, {
type: 'warning',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-warning',
onClick: () => handleOtherAction(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineSetting),
}),
default: () => '设置',
}))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
},
}
```
- 编辑按钮:`type: 'primary'`、`size: 'small'`、`class: 'action-btn-primary'`,图标固定为 `IconParkOutlineEdit`
- 删除按钮:使用 `NPopconfirm` 包裹 `type: 'error'`、`secondary: true`、`size: 'small'` 的按钮,图标用 `IconParkOutlineDelete`,确认文案统一为“确定删除此记录吗?”。
- 其它功能按钮:根据语义选择 `type: 'warning'`、`'info'` 等,并附加 `secondary: true` 与对应语义图标(如 `IconParkOutlineSetting`)。
- 操作列返回 `div.flex.items-center.justify-center.gap-2` 布局,按钮顺序遵循“编辑→删除→其他”。
### 按钮样式核心标准
@ -1381,7 +1325,7 @@ const buttons = []
buttons.push(h(NButton, {
type: 'primary',
size: 'small',
class: 'action-btn-primary'
class: 'action-btn-primary',
// ... 其他属性
}))
@ -1392,7 +1336,7 @@ buttons.push(h(NPopconfirm, {
type: 'error',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-danger'
class: 'action-btn-secondary action-btn-danger',
// ... 其他属性
})
}))
@ -1402,7 +1346,7 @@ buttons.push(h(NButton, {
type: 'info',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-info'
class: 'action-btn-secondary action-btn-info',
// ... 其他属性
}))
```

View File

@ -0,0 +1,71 @@
<template>
<n-tag
v-if="option"
:type="tagType"
:style="tagStyle"
:size="size"
:bordered="!option.dictColor"
class="dict-tag"
>
<slot :option="option" :label="option.dictLabel">
{{ option.dictLabel }}
</slot>
</n-tag>
<span v-else class="dict-tag__placeholder">
<slot name="placeholder" :value="value">
{{ fallbackLabel }}
</slot>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TagProps } from 'naive-ui'
import { useDict } from '@/hooks'
type DictValue = string | number | boolean | null | undefined
interface DictTagProps {
dictType: string
value: DictValue
size?: NonNullable<TagProps['size']>
fallbackLabel?: string
}
const props = withDefaults(defineProps<DictTagProps>(), {
size: 'small',
fallbackLabel: '-',
})
const { getDictOption, getDictLabel } = useDict(computed(() => [props.dictType]))
const option = computed(() => getDictOption(props.dictType, props.value))
const tagType = computed<TagProps['type']>(() => {
if (!option.value)
return 'default'
return option.value.dictColor ? 'default' : (option.value.dictTag as TagProps['type']) ?? 'default'
})
const tagStyle = computed(() => {
if (!option.value?.dictColor)
return undefined
return {
borderColor: option.value.dictColor,
backgroundColor: option.value.dictColor,
color: '#fff',
} satisfies Record<string, string>
})
const fallbackLabel = computed(() => getDictLabel(props.dictType, props.value, props.fallbackLabel))
const value = computed(() => props.value)
</script>
<style scoped>
.dict-tag__placeholder {
color: var(--n-text-color);
}
</style>

View File

@ -1,2 +1,3 @@
export * from './useBoolean'
export * from './usePermission'
export * from './useDict'

75
src/hooks/useDict.ts Normal file
View File

@ -0,0 +1,75 @@
import type { MaybeRef } from 'vue'
import { computed, unref, watch } from 'vue'
import { toSelectOptions } from '@/utils/dict'
import { useDictStore } from '@/store'
import type { DictDataOption } from '@/service/api/system/dict'
interface UseDictOptions {
/** 是否在创建时立即加载,默认为 true */
immediate?: boolean
/** 监听类型变化时是否强制刷新 */
force?: boolean
}
export function useDict(dictTypes: MaybeRef<string[] | undefined>, options: UseDictOptions = {}) {
const dictStore = useDictStore()
const normalizedTypes = computed(() => {
const value = unref(dictTypes) ?? []
return value.filter((item): item is string => Boolean(item))
})
const load = async (force = false) => {
const types = normalizedTypes.value
if (!types.length)
return
await dictStore.fetchDicts(types, force)
}
watch(
normalizedTypes,
(types) => {
if (!types.length)
return
void dictStore.fetchDicts(types, options.force ?? false)
},
{ immediate: options.immediate ?? true },
)
const dictOptions = computed<Record<string, DictDataOption[]>>(() => {
const result: Record<string, DictDataOption[]> = {}
normalizedTypes.value.forEach((type) => {
result[type] = dictStore.getDictOptions(type)
})
return result
})
const isLoading = computed(() => normalizedTypes.value.some(type => dictStore.isLoading(type)))
const getDictLabel = (dictType: string, value: unknown, fallback?: string) =>
dictStore.getDictLabel(dictType, value, fallback)
const getDictOption = (dictType: string, value: unknown) =>
dictStore.getDictOption(dictType, value)
const getSelectOptions = (dictType: string) => toSelectOptions(dictStore.getDictOptions(dictType))
const reload = async (targetTypes?: string[]) => {
const types = targetTypes && targetTypes.length ? targetTypes : normalizedTypes.value
if (!types.length)
return
await dictStore.fetchDicts(types, true)
}
return {
dictOptions,
isLoading,
load,
reload,
getDictLabel,
getDictOption,
getSelectOptions,
}
}

102
src/store/dict.ts Normal file
View File

@ -0,0 +1,102 @@
import { getDictDataByType } from '@/service/api/system/dict'
import type { DictDataOption } from '@/service/api/system/dict'
type DictValue = string | number | boolean | null | undefined
export const useDictStore = defineStore('dict-store', () => {
const dictMap = ref<Record<string, DictDataOption[]>>({})
const loadingMap = ref<Record<string, boolean>>({})
const pendingMap: Record<string, Promise<DictDataOption[]>> = {}
function getDictOptions(dictType: string) {
return dictMap.value[dictType] ?? []
}
function getDictOption(dictType: string, value: DictValue) {
if (value === undefined || value === null)
return undefined
const target = String(value)
return getDictOptions(dictType).find(option => option.dictValue === target)
}
function getDictLabel(dictType: string, value: DictValue, fallback?: string) {
const option = getDictOption(dictType, value)
if (option)
return option.dictLabel
if (fallback !== undefined)
return fallback
if (value === undefined || value === null || value === '')
return ''
return String(value)
}
async function fetchDict(dictType: string, force = false) {
if (!dictType)
return [] as DictDataOption[]
if (!force && dictMap.value[dictType])
return dictMap.value[dictType]
if (!force && pendingMap[dictType])
return pendingMap[dictType]
const promise = (async () => {
loadingMap.value[dictType] = true
try {
const { isSuccess, data } = await getDictDataByType(dictType)
if (isSuccess && Array.isArray(data))
dictMap.value[dictType] = data
else if (!dictMap.value[dictType])
dictMap.value[dictType] = []
return dictMap.value[dictType]
}
finally {
loadingMap.value[dictType] = false
delete pendingMap[dictType]
}
})()
if (!force)
pendingMap[dictType] = promise
return promise
}
async function fetchDicts(dictTypes: string[], force = false) {
const uniqueTypes = Array.from(new Set(dictTypes.filter(Boolean)))
await Promise.all(uniqueTypes.map(type => fetchDict(type, force)))
}
function isLoading(dictType: string) {
return Boolean(loadingMap.value[dictType])
}
function invalidate(dictType?: string) {
if (dictType) {
delete dictMap.value[dictType]
delete loadingMap.value[dictType]
delete pendingMap[dictType]
}
else {
dictMap.value = {}
loadingMap.value = {}
Object.keys(pendingMap).forEach(key => delete pendingMap[key])
}
}
return {
dictMap,
fetchDict,
fetchDicts,
getDictOptions,
getDictOption,
getDictLabel,
isLoading,
invalidate,
}
})

View File

@ -5,6 +5,7 @@ export * from './app/index'
export * from './auth'
export * from './router'
export * from './tab'
export * from './dict'
// 安装pinia全局状态库
export function installPinia(app: App) {

65
src/utils/dict.ts Normal file
View File

@ -0,0 +1,65 @@
import type { DictDataOption } from '@/service/api/system/dict'
export type DictValue = string | number | boolean | null | undefined
/**
* Naive UI Select
*/
export function toSelectOptions(dictOptions: DictDataOption[] = []) {
return dictOptions.map(option => ({
label: option.dictLabel,
value: option.dictValue,
}))
}
/**
*
*/
export function findDictOption(dictOptions: DictDataOption[] = [], value: DictValue) {
if (value === undefined || value === null)
return undefined
const target = String(value)
return dictOptions.find(option => option.dictValue === target)
}
/**
*
*/
export function findDictLabel(dictOptions: DictDataOption[] = [], value: DictValue, fallback?: string) {
const target = findDictOption(dictOptions, value)
if (target)
return target.dictLabel
if (fallback !== undefined)
return fallback
if (value === undefined || value === null || value === '')
return ''
return String(value)
}
/**
*
*/
export function findDictColor(dictOptions: DictDataOption[] = [], value: DictValue) {
const target = findDictOption(dictOptions, value)
if (!target)
return undefined
return {
tag: target.dictTag,
color: target.dictColor,
label: target.dictLabel,
}
}
/**
* -> Map便
*/
export function createDictMap(dictOptions: DictDataOption[] = []) {
const map = new Map<string, DictDataOption>()
dictOptions.forEach(option => map.set(option.dictValue, option))
return map
}

View File

@ -97,13 +97,7 @@
<div class="flex justify-between items-center py-2">
<span class="text-gray-600">状态</span>
<n-tag
:type="(personalData.userStatus || '0') === '0' ? 'info' : 'error'"
:style="(personalData.userStatus || '0') === '0' ? { backgroundColor: '#6366f1', color: 'white', border: 'none' } : {}"
size="small"
>
{{ getStatusText(personalData.userStatus || '0') }}
</n-tag>
<DictTag dict-type="sys_switch_status" :value="personalData.userStatus || '0'" />
</div>
<div class="flex justify-between items-center py-2">
@ -260,12 +254,14 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from '@/store/auth'
import { coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { getPersonalData, updateBasicData, updatePassword, uploadAvatar } from '@/service/api/personal'
import type { PersonalDataVo, UpdatePasswordBo, UpdatePersonalBo } from '@/service/api/personal'
import { serviceConfig } from '@/../service.config'
import DictTag from '@/components/common/DictTag.vue'
import { useDict } from '@/hooks'
//
const authStore = useAuthStore()
@ -343,29 +339,12 @@ const passwordRules = {
],
}
//
const genderOptions = [
{ label: '男', value: '1' },
{ label: '女', value: '2' },
{ label: '未知', value: '3' },
]
const { getSelectOptions, getDictLabel } = useDict(['sys_user_sex', 'sys_switch_status'])
//
const statusOptions = [
{ label: '启用', value: '0' },
{ label: '停用', value: '1' },
]
const genderOptions = computed(() => getSelectOptions('sys_user_sex'))
//
function getGenderText(sex: string) {
const option = genderOptions.find(item => item.value === sex)
return option?.label || '未知'
}
//
function getStatusText(status: string) {
const option = statusOptions.find(item => item.value === status)
return option?.label || '未知'
return getDictLabel('sys_user_sex', sex, '未知')
}
//

View File

@ -327,7 +327,8 @@ import { NButton, NIcon, NInputNumber, NPopconfirm, NSpace, NSwitch, NTag } from
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import { usePermission } from '@/hooks/usePermission'
import { useDict, usePermission } from '@/hooks'
import { useDictStore } from '@/store'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import {
addDictData,
@ -374,6 +375,9 @@ const currentDictType = ref<DictTypeOption | null>(null)
const dictTypeOptions = ref<{ label: string, value: string }[]>([])
const isFromTypeClick = ref(false)
const dictStore = useDictStore()
const { getSelectOptions, getDictLabel } = useDict(['sys_switch_status', 'sys_tag_type'])
//
const searchForm = reactive<DictDataSearchForm>({
dictType: '',
@ -414,18 +418,8 @@ function handlePageSizeChange(pageSize: number) {
}
//
const statusOptions = [
{ label: '正常', value: DictStatus.NORMAL },
{ label: '停用', value: DictStatus.DISABLED },
]
//
const tagOptions = [
{ label: 'primary', value: DictTag.PRIMARY },
{ label: 'success', value: DictTag.SUCCESS },
{ label: 'info', value: DictTag.INFO },
{ label: 'warning', value: DictTag.WARNING },
]
const statusOptions = computed(() => getSelectOptions('sys_switch_status'))
const tagOptions = computed(() => getSelectOptions('sys_tag_type'))
//
const rules: FormRules = {
@ -550,7 +544,7 @@ const columns: DataTableColumns<DictDataVo> = [
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要${row.dictStatus === DictStatus.NORMAL ? '停用' : '启用'}字典数据「${row.dictLabel}」吗?`,
default: () => `确定要将字典数据「${row.dictLabel}」状态切换为「${getDictLabel('sys_switch_status', row.dictStatus === DictStatus.NORMAL ? '1' : '0')}」吗?`,
trigger: () => h(NSwitch, {
value: row.dictStatus === DictStatus.NORMAL,
size: 'small',
@ -822,6 +816,7 @@ async function handleDelete(row: DictDataVo) {
if (isSuccess) {
coiMsgSuccess('删除成功')
dictStore.invalidate(row.dictType)
getTableData()
}
else {
@ -849,6 +844,8 @@ async function handleBatchDelete() {
coiMsgSuccess('批量删除成功')
checkedRowKeys.value = []
selectedRows.value = []
if (searchForm.dictType)
dictStore.invalidate(searchForm.dictType)
getTableData()
}
else {
@ -868,7 +865,9 @@ async function handleStatusChange(row: DictDataVo) {
const { isSuccess } = await updateDictDataStatus(row.dictId, newStatus)
if (isSuccess) {
coiMsgSuccess('状态修改成功')
const statusLabel = getDictLabel('sys_switch_status', newStatus, newStatus === DictStatus.NORMAL ? '启用' : '停用')
coiMsgSuccess(`状态已更新为「${statusLabel}`)
dictStore.invalidate(row.dictType)
getTableData()
}
else {
@ -888,6 +887,7 @@ async function handleSyncCache() {
if (isSuccess) {
coiMsgSuccess('缓存同步成功')
dictStore.invalidate()
}
else {
coiMsgError('缓存同步失败')
@ -936,6 +936,8 @@ async function handleSubmit() {
if (isSuccess) {
coiMsgSuccess(isEdit.value ? '修改成功' : '新增成功')
formDialogRef.value?.coiClose()
if (submitData.dictType)
dictStore.invalidate(submitData.dictType)
getTableData()
}
else {

View File

@ -251,7 +251,8 @@ import { NButton, NIcon, NPopconfirm, NSpace, NSwitch } from 'naive-ui'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import { usePermission } from '@/hooks/usePermission'
import { useDict, usePermission } from '@/hooks'
import { useDictStore } from '@/store'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import {
addDictType,
@ -276,6 +277,7 @@ import IconParkOutlineEdit from '~icons/icon-park-outline/edit'
//
const router = useRouter()
const { hasPermission } = usePermission()
const { getSelectOptions, getDictLabel } = useDict(['sys_switch_status'])
//
const loading = ref(false)
@ -287,6 +289,7 @@ const formRef = ref<FormInst>()
const formDialogRef = ref()
const isEdit = ref(false)
const modalTitle = ref('')
const dictStore = useDictStore()
//
const searchForm = reactive<DictTypeSearchForm>({
@ -324,10 +327,7 @@ function handlePageSizeChange(pageSize: number) {
}
//
const statusOptions = [
{ label: '正常', value: DictStatus.NORMAL },
{ label: '停用', value: DictStatus.DISABLED },
]
const statusOptions = computed(() => getSelectOptions('sys_switch_status'))
//
const rules: FormRules = {
@ -411,10 +411,12 @@ const columns: DataTableColumns<DictTypeVo> = [
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要${row.dictStatus === DictStatus.NORMAL ? '停用' : '启用'}字典类型「${row.dictName}」吗?`,
default: () => `确定要将字典类型「${row.dictName}」状态切换为「${getDictLabel('sys_switch_status', row.dictStatus === DictStatus.NORMAL ? '1' : '0')}」吗?`,
trigger: () => h(NSwitch, {
value: row.dictStatus === DictStatus.NORMAL,
size: 'small',
checkedChildren: getDictLabel('sys_switch_status', '0', '启用'),
uncheckedChildren: getDictLabel('sys_switch_status', '1', '停用'),
}),
}),
])
@ -595,6 +597,7 @@ async function handleDelete(row: DictTypeVo) {
if (isSuccess) {
coiMsgSuccess('删除成功')
dictStore.invalidate()
getTableData()
}
else {
@ -622,6 +625,7 @@ async function handleBatchDelete() {
coiMsgSuccess('批量删除成功')
checkedRowKeys.value = []
selectedRows.value = []
dictStore.invalidate()
getTableData()
}
else {
@ -641,7 +645,9 @@ async function handleStatusChange(row: DictTypeVo) {
const { isSuccess } = await updateDictTypeStatus(row.dictId, newStatus)
if (isSuccess) {
coiMsgSuccess('状态修改成功')
const statusLabel = getDictLabel('sys_switch_status', newStatus, newStatus === DictStatus.NORMAL ? '启用' : '停用')
coiMsgSuccess(`状态已更新为「${statusLabel}`)
dictStore.invalidate()
getTableData()
}
else {
@ -661,6 +667,7 @@ async function handleSyncCache() {
if (isSuccess) {
coiMsgSuccess('缓存同步成功')
dictStore.invalidate()
}
else {
coiMsgError('缓存同步失败')
@ -704,6 +711,7 @@ async function handleSubmit() {
if (isSuccess) {
coiMsgSuccess(isEdit.value ? '修改成功' : '新增成功')
formDialogRef.value?.coiClose()
dictStore.invalidate()
getTableData()
}
else {

View File

@ -72,7 +72,7 @@
v-model:value="searchForm.fileService"
placeholder="请选择存储类型"
clearable
:options="FILE_SERVICE_DB_OPTIONS"
:options="getSelectOptions('sys_file_service')"
/>
</n-form-item>
</n-grid-item>
@ -215,11 +215,7 @@
<n-select
v-model:value="uploadForm.fileService"
placeholder="请选择文件服务"
:options="[
{ label: 'LOCAL', value: 'LOCAL' },
{ label: 'OSS', value: 'OSS' },
{ label: 'MINIO', value: 'MINIO' },
]"
:options="getSelectOptions('sys_file_service')"
/>
</n-form-item>
@ -284,7 +280,8 @@ import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import CoiImageViewer from '@/components/common/CoiImageViewer.vue'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { usePermission } from '@/hooks/usePermission'
import DictTag from '@/components/common/DictTag.vue'
import { useDict, usePermission } from '@/hooks'
import { PERMISSIONS } from '@/constants/permissions'
import {
batchDeleteSysFiles,
@ -295,7 +292,6 @@ import {
import type { SysFileQueryBo, SysFileSearchForm, SysFileVo } from '@/service/api/system/file'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineDownload from '~icons/icon-park-outline/download'
import { FILE_SERVICE_DB_OPTIONS } from '@/service/api/system/file/types'
//
const StarIcon = () => h('span', { class: 'text-yellow-500' }, '✨')
@ -321,6 +317,27 @@ const fileCategories = [
//
const { hasPermission } = usePermission()
const { getSelectOptions } = useDict(['sys_file_service'])
const storageValueToType: Record<string, string> = {
1: 'LOCAL',
2: 'MINIO',
3: 'OSS',
}
const storageTypeToValue: Record<string, string> = {
LOCAL: '1',
MINIO: '2',
OSS: '3',
}
function mapServiceValueToType(value: string | null | undefined) {
const str = value != null ? String(value) : '1'
if (storageValueToType[str])
return storageValueToType[str]
const upper = str.toUpperCase()
return storageValueToType[storageTypeToValue[upper] ?? '1'] ?? 'LOCAL'
}
//
const searchFormRef = ref<FormInst>()
@ -376,7 +393,7 @@ const isConfirmDisabled = computed(() => {
//
const uploadForm = ref({
fileService: 'LOCAL',
fileService: '1',
filePath: '',
})
@ -481,19 +498,8 @@ const columns: DataTableColumns<SysFileVo> = [
title: '文件服务类型',
key: 'fileService',
align: 'center',
width: 120,
render: (row) => {
const serviceMap: Record<string, { type: 'success' | 'info' | 'warning', text: string }> = {
1: { type: 'success', text: '本地存储' },
2: { type: 'info', text: 'MinIO存储' },
3: { type: 'warning', text: '阿里云OSS' },
}
const config = serviceMap[row.fileService] || { type: 'info', text: '未知' }
return h(NTag, {
type: config.type,
size: 'small',
}, { default: () => config.text })
},
width: 140,
render: row => h(DictTag, { dictType: 'sys_file_service', value: row.fileService }),
},
{
title: '创建时间',
@ -781,7 +787,8 @@ async function customUpload({ file, onProgress, onFinish, onError }: any) {
onProgress({ percent: 10 })
// API
const result = await uploadFile(fileObj, folderName, 2, '-1', uploadForm.value.fileService)
const storageType = mapServiceValueToType(uploadForm.value.fileService)
const result = await uploadFile(fileObj, folderName, 2, '-1', storageType)
//
if (result.isSuccess === false) {

View File

@ -51,10 +51,7 @@
v-model:value="searchForm.loginStatus"
placeholder="请选择登录状态"
clearable
:options="[
{ label: '成功', value: '0' },
{ label: '失败', value: '1' },
]"
:options="loginStatusOptions"
/>
</n-form-item>
</n-grid-item>
@ -196,12 +193,13 @@
</template>
<script setup lang="ts">
import { h, nextTick, onMounted, ref } from 'vue'
import { computed, h, nextTick, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NTag } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import DictTag from '@/components/common/DictTag.vue'
import {
batchDeleteLoginLog,
deleteLoginLog,
@ -210,10 +208,21 @@ import {
import type { LoginLogVo } from '@/service/api/system/loginlog'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks/usePermission'
import { useDict, usePermission } from '@/hooks'
//
const { hasPermission } = usePermission()
const { dictOptions } = useDict(['sys_common_status'])
const loginStatusOptions = computed(() => {
const options = dictOptions.sys_common_status || []
return options
.filter(option => option.dictValue === '1' || option.dictValue === '2')
.map(option => ({
label: option.dictLabel,
value: option.dictValue === '1' ? '0' : '1',
}))
})
//
interface LoginLogSearchForm {
@ -320,17 +329,9 @@ const columns: DataTableColumns<LoginLogVo> = [
{
title: '登录状态',
key: 'loginStatus',
width: 100,
width: 110,
align: 'center',
render: (row) => {
const isSuccess = row.loginStatus === '0'
return h(NTag, {
type: isSuccess ? 'success' : 'error',
size: 'small',
}, {
default: () => isSuccess ? '操作成功' : '操作失败',
})
},
render: row => h(DictTag, { dictType: 'sys_common_status', value: row.loginStatus === '0' ? '1' : '2' }),
},
{
title: '登录信息',

View File

@ -40,10 +40,7 @@
v-model:value="searchForm.menuStatus"
placeholder="请选择菜单状态"
clearable
:options="[
{ label: '启用', value: '0' },
{ label: '停用', value: '1' },
]"
:options="getSelectOptions('sys_switch_status')"
/>
</n-form-item>
</n-grid-item>
@ -235,7 +232,7 @@
:style="formData.parentId === '0' ? { color: themeColors.primary, fontWeight: '500' } : {}"
@click="handleSelectParent('0', '最顶级菜单')"
>
<n-radio
<NRadio
:checked="formData.parentId === '0'"
style="pointer-events: none;"
/>
@ -263,7 +260,7 @@
@click="handleSelectParent(menu.value, menu.label)"
@mouseenter="handleMenuHover(menu)"
>
<n-radio
<NRadio
:checked="formData.parentId === menu.value"
style="pointer-events: none;"
/>
@ -303,7 +300,7 @@
:style="formData.parentId === submenu.value ? { color: themeColors.primary, fontWeight: '500' } : {}"
@click="handleSelectParent(submenu.value, submenu.label)"
>
<n-radio
<NRadio
:checked="formData.parentId === submenu.value"
style="pointer-events: none;"
/>
@ -325,20 +322,18 @@
<!-- 菜单类型 -->
<n-form-item label="菜单类型" path="menuType" class="mb-2">
<n-radio-group
<NRadioGroup
v-model:value="formData.menuType"
@update:value="handleMenuTypeChange"
>
<n-radio value="1">
目录
</n-radio>
<n-radio value="2">
菜单
</n-radio>
<n-radio value="3">
按钮
</n-radio>
</n-radio-group>
<NRadio
v-for="item in dictOptions.sys_menu_type || []"
:key="item.dictValue"
:value="item.dictValue"
>
{{ item.dictLabel }}
</NRadio>
</NRadioGroup>
</n-form-item>
<!-- 菜单图标 -->
@ -392,14 +387,14 @@
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="是否隐藏" path="isHide">
<n-radio-group v-model:value="formData.isHide">
<n-radio value="0">
<NRadioGroup v-model:value="formData.isHide">
<NRadio value="0">
</n-radio>
<n-radio value="1">
</NRadio>
<NRadio value="1">
</n-radio>
</n-radio-group>
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
<n-grid-item>
@ -438,14 +433,14 @@
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="是否缓存" path="isKeepAlive">
<n-radio-group v-model:value="formData.isKeepAlive">
<n-radio value="0">
<NRadioGroup v-model:value="formData.isKeepAlive">
<NRadio value="0">
</n-radio>
<n-radio value="1">
</NRadio>
<NRadio value="1">
</n-radio>
</n-radio-group>
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
<n-grid-item>
@ -462,26 +457,26 @@
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="是否展开" path="isSpread">
<n-radio-group v-model:value="formData.isSpread">
<n-radio value="0">
<NRadioGroup v-model:value="formData.isSpread">
<NRadio value="0">
</n-radio>
<n-radio value="1">
</NRadio>
<NRadio value="1">
</n-radio>
</n-radio-group>
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="是否固钉" path="isAffix">
<n-radio-group v-model:value="formData.isAffix">
<n-radio value="0">
<NRadioGroup v-model:value="formData.isAffix">
<NRadio value="0">
</n-radio>
<n-radio value="1">
</NRadio>
<NRadio value="1">
</n-radio>
</n-radio-group>
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
</n-grid>
@ -492,26 +487,26 @@
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="是否展开" path="isSpread">
<n-radio-group v-model:value="formData.isSpread">
<n-radio value="0">
<NRadioGroup v-model:value="formData.isSpread">
<NRadio value="0">
</n-radio>
<n-radio value="1">
</NRadio>
<NRadio value="1">
</n-radio>
</n-radio-group>
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="是否固钉" path="isAffix">
<n-radio-group v-model:value="formData.isAffix">
<n-radio value="0">
<NRadioGroup v-model:value="formData.isAffix">
<NRadio value="0">
</n-radio>
<n-radio value="1">
</NRadio>
<NRadio value="1">
</n-radio>
</n-radio-group>
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
</n-grid>
@ -535,7 +530,7 @@
<script setup lang="ts">
import { computed, h, nextTick, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NSwitch, NTag } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NRadio, NRadioGroup, NSpace, NSwitch, NTag } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineEdit from '~icons/icon-park-outline/edit'
import IconParkOutlinePlus from '~icons/icon-park-outline/plus'
@ -543,8 +538,9 @@ import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiIcon from '@/components/common/CoiIcon.vue'
import IconSelect from '@/components/common/IconSelect.vue'
import DictTag from '@/components/common/DictTag.vue'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks'
import { useDict, usePermission } from '@/hooks'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import {
addMenu,
@ -587,6 +583,11 @@ const selectedRows = ref<MenuVo[]>([])
const expandedKeys = ref<string[]>([])
const isAllExpanded = ref(true)
const { dictOptions, getSelectOptions, getDictLabel } = useDict([
'sys_menu_type',
'sys_switch_status',
])
//
const menuDialogRef = ref()
const formRef = ref<FormInst>()
@ -716,15 +717,7 @@ const columns: DataTableColumns<MenuVo> = [
key: 'menuType',
width: 100,
align: 'center',
render: (row) => {
const typeMap = {
1: { label: '目录', color: 'primary' },
2: { label: '菜单', color: 'info' },
3: { label: '按钮', color: 'warning' },
}
const type = typeMap[Number(row.menuType) as keyof typeof typeMap]
return h(NTag, { type: type.color }, { default: () => type.label })
},
render: row => h(DictTag, { dictType: 'sys_menu_type', value: row.menuType }),
},
{
title: '展开/折叠',
@ -818,6 +811,8 @@ const columns: DataTableColumns<MenuVo> = [
render: (row) => {
return h(NSwitch, {
value: row.menuStatus === '0',
checkedChildren: getDictLabel('sys_switch_status', '0', '启用'),
uncheckedChildren: getDictLabel('sys_switch_status', '1', '停用'),
onUpdateValue: value => handleStatusChange(row, value ? '0' : '1'),
})
},
@ -1315,7 +1310,8 @@ async function handleStatusChange(menu: MenuVo, status: string) {
//
updateMenuStatusInData(tableData.value, menu.menuId, status)
coiMsgSuccess('状态修改成功')
const statusLabel = getDictLabel('sys_switch_status', status, status === '0' ? '启用' : '停用')
coiMsgSuccess(`状态已更新为「${statusLabel}`)
}
catch {
coiMsgError('状态修改失败')

View File

@ -40,10 +40,7 @@
v-model:value="searchForm.operStatus"
placeholder="请选择操作状态"
clearable
:options="[
{ label: '操作成功', value: '0' },
{ label: '操作失败', value: '1' },
]"
:options="operStatusOptions"
/>
</n-form-item>
</n-grid-item>
@ -233,9 +230,7 @@
{{ currentLogDetail.costTime || '-' }}
</n-descriptions-item>
<n-descriptions-item label="操作状态">
<NTag :type="currentLogDetail.operStatus === '0' ? 'success' : 'error'" size="small">
{{ currentLogDetail.operStatus === '0' ? '操作成功' : '操作失败' }}
</NTag>
<DictTag dict-type="sys_common_status" :value="currentLogDetail.operStatus === '0' ? '1' : '2'" />
</n-descriptions-item>
<n-descriptions-item v-if="currentLogDetail.operParam" label="请求参数" :span="2">
<div class="json-container">
@ -255,7 +250,7 @@
</template>
<script setup lang="ts">
import { h, nextTick, onMounted, ref } from 'vue'
import { computed, h, nextTick, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NTag } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
@ -263,6 +258,7 @@ import IconParkOutlinePreviewOpen from '~icons/icon-park-outline/preview-open'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import DictTag from '@/components/common/DictTag.vue'
import {
batchDeleteOperLog,
clearOperLog,
@ -273,10 +269,21 @@ import {
import type { OperLogSearchForm, OperLogVo } from '@/service/api/system/operlog'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks/usePermission'
import { useDict, usePermission } from '@/hooks'
//
const { hasButton } = usePermission()
const { dictOptions } = useDict(['sys_oper_type', 'sys_common_status'])
const operStatusOptions = computed(() => {
const options = dictOptions.sys_common_status || []
return options
.filter(option => option.dictValue === '1' || option.dictValue === '2')
.map(option => ({
label: option.dictLabel,
value: option.dictValue === '1' ? '0' : '1',
}))
})
//
const loading = ref(false)
@ -339,16 +346,9 @@ const columns: DataTableColumns<OperLogVo> = [
{
title: '操作类型',
key: 'operType',
width: 90,
width: 110,
align: 'center',
render: (row) => {
return h(NTag, {
type: 'primary',
size: 'small',
}, {
default: () => row.operType,
})
},
render: row => h(DictTag, { dictType: 'sys_oper_type', value: row.operType }),
},
{
title: '操作人员[登录名/用户名]',
@ -419,15 +419,7 @@ const columns: DataTableColumns<OperLogVo> = [
key: 'operStatus',
width: 90,
align: 'center',
render: (row) => {
const isSuccess = row.operStatus === '0'
return h(NTag, {
type: isSuccess ? 'success' : 'error',
size: 'small',
}, {
default: () => isSuccess ? '操作成功' : '操作失败',
})
},
render: row => h(DictTag, { dictType: 'sys_common_status', value: row.operStatus === '0' ? '1' : '2' }),
},
{
title: '操作时间',

View File

@ -72,7 +72,7 @@
v-model:value="searchForm.pictureService"
placeholder="请选择存储类型"
clearable
:options="PICTURE_SERVICE_OPTIONS"
:options="getSelectOptions('sys_file_service')"
/>
</n-form-item>
</n-grid-item>
@ -258,7 +258,9 @@
<div><span class="text-gray-400">新名称:</span> {{ picture.newName }}</div>
<div><span class="text-gray-400">文件大小:</span> {{ picture.pictureSize }}</div>
<div><span class="text-gray-400">文件后缀:</span> {{ picture.pictureSuffix?.toUpperCase() }}</div>
<div><span class="text-gray-400">服务类型:</span> {{ getPictureServiceText(picture.pictureService) }}</div>
<div class="flex items-center gap-1">
<span class="text-gray-400">服务类型:</span> <DictTag dict-type="sys_file_service" :value="mapStorageTypeToValue(picture.pictureService)" />
</div>
<div><span class="text-gray-400">创建时间:</span> {{ picture.createTime ? new Date(picture.createTime).toLocaleString() : '--' }}</div>
<div><span class="text-gray-400">创建者:</span> {{ picture.createBy || '--' }}</div>
</div>
@ -348,11 +350,7 @@
<n-select
v-model:value="uploadForm.pictureService"
placeholder="请选择服务类型"
:options="[
{ label: 'LOCAL', value: 'LOCAL' },
{ label: 'OSS', value: 'OSS' },
{ label: 'MINIO', value: 'MINIO' },
]"
:options="getSelectOptions('sys_file_service')"
/>
</n-form-item>
@ -417,13 +415,13 @@ import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import CoiImageViewer from '@/components/common/CoiImageViewer.vue'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { usePermission } from '@/hooks/usePermission'
import DictTag from '@/components/common/DictTag.vue'
import { useDict, usePermission } from '@/hooks'
import { PERMISSIONS } from '@/constants/permissions'
import {
batchDeleteSysPictures,
deleteSysPicture,
getSysPictureList,
PICTURE_SERVICE_OPTIONS,
PICTURE_TYPE_OPTIONS,
uploadPicture,
} from '@/service/api/system/picture'
@ -458,6 +456,35 @@ const uploadPictureTypeOptions = PICTURE_TYPE_OPTIONS.filter(option => option.va
//
const { hasPermission } = usePermission()
const { getSelectOptions } = useDict(['sys_file_service'])
const storageValueToType: Record<string, string> = {
1: 'LOCAL',
2: 'MINIO',
3: 'OSS',
}
const storageTypeToValue: Record<string, string> = {
LOCAL: '1',
MINIO: '2',
OSS: '3',
}
function mapServiceValueToType(value: string | null | undefined) {
const str = value != null ? String(value) : '1'
if (storageValueToType[str])
return storageValueToType[str]
const upper = str.toUpperCase()
return storageValueToType[storageTypeToValue[upper] ?? '1'] ?? 'LOCAL'
}
function mapStorageTypeToValue(type: string | null | undefined) {
const str = type != null ? String(type) : '1'
if (storageValueToType[str])
return str
const upper = str.toUpperCase()
return storageTypeToValue[upper] ?? '1'
}
//
const searchFormRef = ref<FormInst>()
@ -514,7 +541,7 @@ const isConfirmDisabled = computed(() => {
//
const uploadForm = ref({
pictureService: 'LOCAL',
pictureService: '1',
pictureType: '9', //
picturePath: '',
})
@ -622,20 +649,9 @@ const columns: DataTableColumns<SysPictureVo> = [
{
title: '服务类型',
key: 'pictureService',
width: 120,
width: 130,
align: 'center',
render: (row) => {
const serviceMap: Record<string, { type: 'success' | 'info' | 'warning', text: string }> = {
1: { type: 'success', text: '本地存储' },
2: { type: 'info', text: 'MinIO存储' },
3: { type: 'warning', text: '阿里云OSS' },
}
const config = serviceMap[row.pictureService] || { type: 'info', text: '未知' }
return h(NTag, {
type: config.type,
size: 'small',
}, { default: () => config.text })
},
render: row => h(DictTag, { dictType: 'sys_file_service', value: mapStorageTypeToValue(row.pictureService) }),
},
{
title: '创建时间',
@ -837,21 +853,12 @@ function handleDownload(row: SysPictureVo) {
}
//
function getPictureServiceText(serviceType: string): string {
const serviceMap: Record<string, string> = {
1: '本地存储',
2: 'MinIO存储',
3: '阿里云OSS',
}
return serviceMap[serviceType] || '未知'
}
//
//
function handleUpload() {
uploadForm.value = {
pictureService: 'LOCAL',
pictureService: '1',
pictureType: selectedCategory.value === '0' ? '9' : selectedCategory.value, //
picturePath: '',
}
@ -922,7 +929,8 @@ async function customUpload({ file, onProgress, onFinish, onError }: any) {
onProgress({ percent: 10 })
// API - 使
const result = await uploadPicture(fileObj, uploadForm.value.pictureType, 2, uploadForm.value.pictureService)
const storageType = mapServiceValueToType(uploadForm.value.pictureService)
const result = await uploadPicture(fileObj, uploadForm.value.pictureType, 2, storageType)
//
if (result.isSuccess === false) {

View File

@ -61,10 +61,7 @@
v-model:value="searchForm.roleStatus"
placeholder="请选择角色状态"
clearable
:options="[
{ label: '启用', value: '0' },
{ label: '停用', value: '1' },
]"
:options="getSelectOptions('sys_switch_status')"
/>
</n-form-item>
</n-grid-item>
@ -269,14 +266,15 @@
<n-grid :cols="2" :x-gap="10">
<n-grid-item>
<n-form-item label="角色状态" path="roleStatus" class="mb-2">
<n-select
v-model:value="formData.roleStatus"
placeholder="请选择角色状态"
:options="[
{ label: '启用', value: '0' },
{ label: '停用', value: '1' },
]"
/>
<NRadioGroup v-model:value="formData.roleStatus">
<NRadio
v-for="item in dictOptions.sys_switch_status || []"
:key="item.dictValue"
:value="item.dictValue"
>
{{ item.dictLabel }}
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
<n-grid-item>
@ -420,13 +418,14 @@
<script setup lang="ts">
import { h, nextTick, onMounted, ref, watch } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NSwitch, NTag, NTree } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NRadio, NRadioGroup, NSpace, NSwitch, NTag, NTree } from 'naive-ui'
import IconParkOutlineEditOne from '~icons/icon-park-outline/edit-one'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineKey from '~icons/icon-park-outline/key'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import DictTag from '@/components/common/DictTag.vue'
import {
addRole,
batchDeleteRoles,
@ -446,10 +445,11 @@ import {
import type { MenuPermissionData, MenuVo } from '@/service/api/system/menu'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks/usePermission'
import { useDict, usePermission } from '@/hooks'
//
const { hasButton } = usePermission()
const { dictOptions, getSelectOptions, getDictLabel } = useDict(['sys_switch_status'])
//
const loading = ref(false)
@ -546,28 +546,27 @@ const columns: DataTableColumns<RoleVo> = [
key: 'roleCode',
width: 150,
align: 'center',
render: (row) => {
return h(NTag, { type: 'primary', size: 'small' }, { default: () => row.roleCode })
},
render: row => h(NTag, { type: 'primary', size: 'small' }, { default: () => row.roleCode }),
},
{
title: '角色状态',
key: 'roleStatus',
width: 100,
width: 120,
align: 'center',
render: (row) => {
return h('div', { class: 'flex items-center justify-center' }, [
return h('div', { class: 'flex items-center justify-center gap-2' }, [
h(DictTag, { dictType: 'sys_switch_status', value: row.roleStatus }),
h(NPopconfirm, {
onPositiveClick: () => handleToggleStatus(row),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要${row.roleStatus === '0' ? '停用' : '启用'}角色「${row.roleName}」吗?`,
default: () => `确定要将角色「${row.roleName}」状态切换为「${getDictLabel('sys_switch_status', row.roleStatus === '0' ? '1' : '0')}」吗?`,
trigger: () => h(NSwitch, {
value: row.roleStatus === '0',
size: 'small',
checkedChildren: '启用',
uncheckedChildren: '停用',
checkedChildren: getDictLabel('sys_switch_status', '0', '启用'),
uncheckedChildren: getDictLabel('sys_switch_status', '1', '停用'),
loading: false,
}),
}),
@ -966,7 +965,7 @@ async function handleBatchDelete() {
async function handleToggleStatus(role: RoleVo) {
try {
const newStatus = role.roleStatus === '0' ? '1' : '0'
const statusText = newStatus === '0' ? '启用' : '停用'
const statusText = getDictLabel('sys_switch_status', newStatus, newStatus === '0' ? '启用' : '停用')
const { isSuccess } = await updateRoleStatus(role.roleId, newStatus)
if (isSuccess) {

View File

@ -72,10 +72,7 @@
v-model:value="searchForm.userStatus"
placeholder="请选择用户状态"
clearable
:options="[
{ label: '启用', value: '0' },
{ label: '停用', value: '1' },
]"
:options="getSelectOptions('sys_switch_status')"
/>
</n-form-item>
</n-grid-item>
@ -313,11 +310,7 @@
<n-select
v-model:value="formData.userType"
placeholder="请选择用户类型"
:options="[
{ label: '系统用户', value: '1' },
{ label: '注册用户', value: '2' },
{ label: '微信用户', value: '3' },
]"
:options="getSelectOptions('sys_user_type')"
/>
</n-form-item>
</n-grid-item>
@ -327,11 +320,7 @@
<n-select
v-model:value="formData.sex"
placeholder="请选择性别"
:options="[
{ label: '男', value: '1' },
{ label: '女', value: '2' },
{ label: '未知', value: '3' },
]"
:options="getSelectOptions('sys_user_sex')"
/>
</n-form-item>
</n-grid-item>
@ -367,14 +356,15 @@
<n-grid-item>
<n-form-item label="用户状态" path="userStatus" class="mb-2">
<n-radio-group v-model:value="formData.userStatus">
<n-radio value="0">
启用
</n-radio>
<n-radio value="1">
停用
</n-radio>
</n-radio-group>
<NRadioGroup v-model:value="formData.userStatus">
<NRadio
v-for="item in dictOptions.sys_switch_status || []"
:key="item.dictValue"
:value="item.dictValue"
>
{{ item.dictLabel }}
</NRadio>
</NRadioGroup>
</n-form-item>
</n-grid-item>
@ -585,11 +575,8 @@
{{ currentAvatarUser?.avatar ? '用户头像' : '默认头像' }}
</p>
<div class="flex items-center justify-center gap-4 text-xs text-gray-400">
<span>用户类型: {{
currentAvatarUser?.userType === '1' ? '系统用户'
: currentAvatarUser?.userType === '2' ? '注册用户' : '微信用户'
}}</span>
<span>状态: {{ currentAvatarUser?.userStatus === '0' ? '启用' : '停用' }}</span>
<span>用户类型: {{ getDictLabel('sys_user_type', currentAvatarUser?.userType, '--') }}</span>
<span>状态: {{ getDictLabel('sys_switch_status', currentAvatarUser?.userStatus, '--') }}</span>
</div>
</div>
</div>
@ -704,7 +691,7 @@
<script setup lang="ts">
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NCheckbox, NDropdown, NIcon, NPopconfirm, NProgress, NSpace, NSwitch, NTag, NUpload, NUploadDragger } from 'naive-ui'
import { NButton, NCheckbox, NDropdown, NIcon, NPopconfirm, NProgress, NRadio, NRadioGroup, NSpace, NSwitch, NTag, NUpload, NUploadDragger } from 'naive-ui'
import IconParkOutlineEditOne from '~icons/icon-park-outline/edit-one'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineRefresh from '~icons/icon-park-outline/refresh'
@ -715,6 +702,7 @@ import IconParkOutlineFileCodeOne from '~icons/icon-park-outline/file-code-one'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import DictTag from '@/components/common/DictTag.vue'
import {
addUser,
batchDeleteUsers,
@ -739,7 +727,7 @@ import {
import type { RoleVo } from '@/service/api/system/role'
import { coiMsgBox, coiMsgError, coiMsgInfo, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks/usePermission'
import { useDict, usePermission } from '@/hooks'
//
const { hasButton } = usePermission()
@ -790,6 +778,12 @@ const uploadProgress = ref(0)
const progressInterval = ref<NodeJS.Timeout | null>(null)
const createdBlobUrls = ref<string[]>([])
const { dictOptions, getSelectOptions, getDictLabel } = useDict([
'sys_user_type',
'sys_user_sex',
'sys_switch_status',
])
//
const pagination = ref({
page: 1,
@ -956,33 +950,14 @@ const columns: DataTableColumns<UserVo> = [
key: 'userType',
width: 100,
align: 'center',
render: (row) => {
const typeMap: Record<string, { label: string, type: any }> = {
1: { label: '系统用户', type: 'primary' },
2: { label: '注册用户', type: 'info' },
3: { label: '微信用户', type: 'warning' },
}
const config = typeMap[row.userType] || { label: '未知', type: 'default' }
return h(NTag, { type: config.type, size: 'small' }, { default: () => config.label })
},
render: row => h(DictTag, { dictType: 'sys_user_type', value: row.userType }),
},
{
title: '性别',
key: 'sex',
width: 80,
align: 'center',
render: (row) => {
const sexMap: Record<string, { label: string, icon: string, color: string }> = {
1: { label: '男', icon: '♂', color: 'text-blue-500' },
2: { label: '女', icon: '♀', color: 'text-pink-500' },
3: { label: '未知', icon: '?', color: 'text-gray-400' },
}
const config = sexMap[row.sex || '3']
return h('div', { class: `flex items-center justify-center gap-1 ${config.color}` }, [
h('span', { class: 'text-lg' }, config.icon),
h('span', { class: 'text-xs' }, config.label),
])
},
render: row => h(DictTag, { dictType: 'sys_user_sex', value: row.sex }),
},
{
title: '用户状态',
@ -990,18 +965,21 @@ const columns: DataTableColumns<UserVo> = [
width: 100,
align: 'center',
render: (row) => {
const nextStatus = row.userStatus === '0' ? '1' : '0'
const enableLabel = getDictLabel('sys_switch_status', '0', '启用')
const disableLabel = getDictLabel('sys_switch_status', '1', '停用')
return h('div', { class: 'flex items-center justify-center' }, [
h(NPopconfirm, {
onPositiveClick: () => handleToggleStatus(row),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要${row.userStatus === '0' ? '停用' : '启用'}用户「${row.userName}」吗?`,
default: () => `确定要将用户「${row.userName}」状态切换为「${getDictLabel('sys_switch_status', nextStatus)}」吗?`,
trigger: () => h(NSwitch, {
value: row.userStatus === '0',
size: 'small',
checkedChildren: '启用',
uncheckedChildren: '停用',
checkedChildren: enableLabel,
uncheckedChildren: disableLabel,
loading: false,
}),
}),
@ -1357,7 +1335,7 @@ async function handleBatchDelete() {
async function handleToggleStatus(user: UserVo) {
try {
const newStatus = user.userStatus === '0' ? '1' : '0'
const statusText = newStatus === '0' ? '启用' : '停用'
const statusText = getDictLabel('sys_switch_status', newStatus, newStatus === '0' ? '启用' : '停用')
const { isSuccess } = await updateUserStatus(user.userId, newStatus)
if (isSuccess) {