diff --git a/src/components/common/DictTag.vue b/src/components/common/DictTag.vue
new file mode 100644
index 0000000..7df1ae5
--- /dev/null
+++ b/src/components/common/DictTag.vue
@@ -0,0 +1,71 @@
+
+
+
+ {{ option.dictLabel }}
+
+
+
+
+ {{ fallbackLabel }}
+
+
+
+
+
+
+
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index a0eb588..efec91d 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,2 +1,3 @@
export * from './useBoolean'
export * from './usePermission'
+export * from './useDict'
diff --git a/src/hooks/useDict.ts b/src/hooks/useDict.ts
new file mode 100644
index 0000000..2a39d2e
--- /dev/null
+++ b/src/hooks/useDict.ts
@@ -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, 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>(() => {
+ const result: Record = {}
+ 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,
+ }
+}
diff --git a/src/store/dict.ts b/src/store/dict.ts
new file mode 100644
index 0000000..a42ce0c
--- /dev/null
+++ b/src/store/dict.ts
@@ -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>({})
+ const loadingMap = ref>({})
+ const pendingMap: Record> = {}
+
+ 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,
+ }
+})
diff --git a/src/store/index.ts b/src/store/index.ts
index acb3543..1df836f 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -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) {
diff --git a/src/utils/dict.ts b/src/utils/dict.ts
new file mode 100644
index 0000000..33f373e
--- /dev/null
+++ b/src/utils/dict.ts
@@ -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()
+ dictOptions.forEach(option => map.set(option.dictValue, option))
+ return map
+}