做字典管理

This commit is contained in:
2026-09-28 23:16:26 +08:00
parent 7c75edcc61
commit c49e8d0d3f
59 changed files with 5005 additions and 4673 deletions
+2
View File
@@ -18,6 +18,8 @@ declare module 'vue' {
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBacktop: typeof import('element-plus/es')['ElBacktop']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
-393
View File
@@ -1,393 +0,0 @@
# Pinia 字典管理系统使用指南
## 系统架构
```
┌─────────────────────────────────────┐
│ API 接口 (getDictItemsByCode) │
│ /api/dict/items/code/{code} │
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Pinia Store (useDictStore) │
│ ✅ 自动缓存字典数据 │
│ ✅ 避免重复请求 │
│ ✅ 支持同步/异步访问 │
└──────────────┬──────────────────────┘
│
┌──────┴──────┐
↓ ↓
┌────────┐ ┌──────────────┐
│组件 │ │Composable │
│直接用 │ │useDict Hook │
└────────┘ └──────────────┘
```
---
## 核心文件说明
### 1. **Store**: `src/stores/dict.js`
字典数据的全局管理器
**主要方法**:
```javascript
import { useDictStore } from '@/stores/dict'
const dictStore = useDictStore()
// ✅ 异步获取字典(推荐)
const items = await dictStore.getDictItems('user_status')
// ✅ 同步获取字典(已缓存时)
const items = dictStore.getDictItemsSync('user_status')
// ✅ 获取字典值对应的标签
const label = dictStore.getDictLabel('user_status', 1)
// ✅ 预加载多个字典
await dictStore.preloadDicts(['user_status', 'user_role'])
// ✅ 清空缓存
dictStore.clearCache('user_status')
```
---
### 2. **常量**: `src/constants/dictCodes.js`
集中管理所有字典编码
**使用示例**:
```javascript
import { DICT_CODES } from '@/constants/dictCodes'
// 好处:避免硬编码,IDE 有自动完成
const items = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
// 所有可用的编码:
DICT_CODES.USER_STATUS // 用户状态
DICT_CODES.USER_GENDER // 用户性别
DICT_CODES.USER_ROLE // 用户角色
DICT_CODES.DEPT_STATUS // 部门状态
DICT_CODES.POSITION_STATUS // 职位状态
// ... 更多编码
```
---
### 3. **Composable**: `src/composables/useDict.js`
简化在组件中使用字典的 Hook
**基础用法**:
```javascript
import { useDictionary, useUserStatusDict } from '@/composables/useDict'
import { DICT_CODES } from '@/constants/dictCodes'
// 方式1:使用常量
const { statusDict, loading } = useDictionary(DICT_CODES.USER_STATUS)
// 方式2:使用字符串
const { dicts, loading } = useDictionary('user_status')
// 方式3:使用特化 Hook(推荐)
const { user_statusDict, loading } = useUserStatusDict()
```
---
## 使用场景
### 场景1:在列表页加载字典
**文件**: `src/views/system/users/index.vue`
```vue
<script setup>
import { useDictStore } from '@/stores/dict'
import { ref, onMounted } from 'vue'
const dictStore = useDictStore()
const statusDict = ref([])
const fetchStatusDict = async () => {
statusDict.value = await dictStore.getDictItems('user_status')
}
onMounted(async () => {
await fetchStatusDict()
})
</script>
<template>
<!-- 传给子组件 -->
<UserEditDialog :status-dict="statusDict" />
</template>
```
---
### 场景2:在编辑对话框使用字典
**文件**: `src/views/system/users/components/UserEdit.vue`
```vue
<template>
<!-- 状态选择 -->
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option
v-for="item in statusDict"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
/>
</el-select>
</el-form-item>
</template>
<script setup>
const props = defineProps({
statusDict: {
type: Array,
default: () => [],
},
})
</script>
```
---
### 场景3:快速使用 Composable Hook
最简单的方式,自动处理加载:
```vue
<template>
<div>
<p v-if="loading">加载中...</p>
<el-select v-else v-model="status">
<el-option
v-for="item in user_statusDict"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
/>
</el-select>
</div>
</template>
<script setup>
import { useUserStatusDict } from '@/composables/useDict'
import { ref } from 'vue'
const status = ref('active')
const { user_statusDict, loading } = useUserStatusDict()
</script>
```
---
### 场景4:预加载应用启动时需要的字典
**文件**: `src/main.js`
```javascript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { useDictStore } from '@/stores/dict'
import { DICT_CODES } from '@/constants/dictCodes'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
// 在应用启动后预加载常用字典
const dictStore = useDictStore()
await dictStore.preloadDicts([
DICT_CODES.USER_STATUS,
DICT_CODES.COMMON_STATUS,
DICT_CODES.YES_NO,
])
app.mount('#app')
```
---
## 字典数据结构
后端返回的字典数据结构:
```json
[
{
"dict_id": 1,
"dict_value": "1",
"dict_label": "启用",
"dict_type": "user_status",
"remarks": "用户启用状态",
"remark": "用户启用状态"
},
{
"dict_id": 2,
"dict_value": "0",
"dict_label": "禁用",
"dict_type": "user_status",
"remarks": "用户禁用状态",
"remark": "用户禁用状态"
}
]
```
**关键字段**:
- `dict_value`: 字典值(存储在数据库中)
- `dict_label`: 字典标签(显示给用户)
- `dict_type`: 字典类型编码(如 'user_status')
---
## 最佳实践
### ✅ DO
1. **使用常量而不是硬编码字符串**
```javascript
// ✅ 好
dictStore.getDictItems(DICT_CODES.USER_STATUS)
// ❌ 差
dictStore.getDictItems('user_status')
```
2. **在父组件加载,通过 props 传给子组件**
```javascript
// ✅ 父组件负责数据,子组件负责展示
// index.vue
const statusDict = await dictStore.getDictItems(DICT_CODES.USER_STATUS)
// UserEdit.vue
const props = defineProps({ statusDict: Array })
```
3. **用 Composable 简化组件逻辑**
```javascript
// ✅ 一行代码搞定
const { user_statusDict, loading } = useUserStatusDict()
```
4. **预加载常用字典**
```javascript
// ✅ 应用启动时预加载,避免页面初始化时加载
await dictStore.preloadDicts([...])
```
### ❌ DON'T
1. **不要在多个地方重复加载同一个字典**
```javascript
// ❌ 糟糕:重复加载
// 页面A
const dict1 = await dictStore.getDictItems('user_status')
// 页面B(Store 会自动缓存,但代码看起来重复)
const dict2 = await dictStore.getDictItems('user_status')
```
2. **不要忘记处理加载状态**
```javascript
// ❌ 可能展示空白
const { statusDict } = useUserStatusDict()
// ✅ 处理加载状态
const { statusDict, loading } = useUserStatusDict()
if (loading) { /* 显示加载中 */ }
```
3. **不要混用不同的字典访问方式**
```javascript
// ❌ 混乱
const dict1 = await dictStore.getDictItems('user_status')
const dict2 = dictStore.getDictItemsSync('user_role')
// ✅ 统一使用
const dict1 = await dictStore.getDictItems('user_status')
const dict2 = await dictStore.getDictItems('user_role')
```
---
## 性能优化建议
| 优化项 | 说明 |
|------|------|
| **缓存** | Store 自动缓存,同一个字典只请求一次 |
| **预加载** | 在路由切换前预加载需要的字典 |
| **同步访问** | 已加载的字典可用 `getDictItemsSync` 同步获取 |
| **避免重复** | 不要在多个组件重复请求同一个字典 |
---
## 故障排查
### 问题1:状态选项为空
**原因**:字典未加载
**解决**:
```javascript
// ❌ 错误:字典还未加载
const statusDict = dictStore.getDictItemsSync('user_status') // 返回 []
// ✅ 正确:等待异步加载完成
const statusDict = await dictStore.getDictItems('user_status')
```
### 问题2:重复加载字典
**原因**:没有使用 Store 的缓存
**解决**:
```javascript
// 所有调用都会自动使用缓存,只请求一次
await dictStore.getDictItems('user_status') // 首次:发送请求
await dictStore.getDictItems('user_status') // 第二次:返回缓存
```
### 问题3:字典显示不对
**原因**:value 类型不匹配(如 1 vs "1")
**解决**:
```javascript
// Store 会自动处理类型匹配
const item = items.find(i =>
String(i.dict_value) === String(value) || i.dict_value === value
)
```
---
## 集成检清表
- [ ] 创建 `src/stores/dict.js` - Store
- [ ] 创建 `src/constants/dictCodes.js` - 常量
- [ ] 创建 `src/composables/useDict.js` - Composable
- [ ] 在 `index.vue` 中导入 `useDictStore`
- [ ] 在 `UserEdit.vue` 中接收 `statusDict` props
- [ ] 测试字典加载和显示
- [ ] 验证缓存功能(打开浏览器 DevTools 检查 Network)
- [ ] 预加载常用字典(可选)
---
## 相关文件修改
已修改的文件:
- ✅ `src/stores/dict.js` - 新建
- ✅ `src/constants/dictCodes.js` - 新建
- ✅ `src/composables/useDict.js` - 新建
- ✅ `src/views/system/users/index.vue` - 使用 `useDictStore`
- ✅ `src/views/system/users/components/UserEdit.vue` - 导入字典库
+63 -54
View File
@@ -1,114 +1,123 @@
import request from '@/utils/request'
// 获取字典类型列表
/**
* 数据字典接口(平台端)
*
* 表:yz_platform_dict / yz_platform_dict_item
* 平台端字典不做租户隔离,与租户端(/backend/dict)各自独立。
*/
const PREFIX = '/platform/dict'
/* ================= 字典列表(字典类型) ================= */
/** 字典列表(分页)params: { page, pageSize, keyword, status } */
export function getDictTypes(params) {
return request({
url: '/platform/dict/types',
url: `${PREFIX}/types`,
method: 'get',
params
})
}
// 根据ID获取字典类型
export function getDictTypeById(id) {
/** 新增字典 */
export function createDictType(data) {
return request({
url: `/platform/dict/types/${id}`,
method: 'get'
})
}
// 添加字典类型
export function addDictType(data) {
return request({
url: '/platform/dict/types',
url: `${PREFIX}/types`,
method: 'post',
data: {
...data,
is_global: data.is_global !== undefined ? data.is_global : 0
}
data
})
}
// 更新字典类型
/** 编辑字典(dict_code 不可改,由后端忽略) */
export function updateDictType(id, data) {
return request({
url: `/platform/dict/types/${id}`,
url: `${PREFIX}/types/${id}`,
method: 'put',
data: {
...data,
is_global: data.is_global !== undefined ? data.is_global : 0
}
data
})
}
// 删除字典类型
/** 删除字典(其下仍有字典项时后端会拒绝) */
export function deleteDictType(id) {
return request({
url: `/platform/dict/types/${id}`,
url: `${PREFIX}/types/${id}`,
method: 'delete'
})
}
// 获取字典项列表
/* ================= 字典项 ================= */
/** 字典项列表(分页)params: { dict_id, page, pageSize, keyword, status } */
export function getDictItems(params) {
return request({
url: '/platform/dict/items',
url: `${PREFIX}/items`,
method: 'get',
params
})
}
// 根据ID获取字典项
export function getDictItemById(id) {
/** 新增字典项 */
export function createDictItem(data) {
return request({
url: `/platform/dict/items/${id}`,
method: 'get'
})
}
// 添加字典项
export function addDictItem(data) {
return request({
url: '/platform/dict/items',
url: `${PREFIX}/items`,
method: 'post',
data
})
}
// 更新字典项
/** 编辑字典项 */
export function updateDictItem(id, data) {
return request({
url: `/platform/dict/items/${id}`,
url: `${PREFIX}/items/${id}`,
method: 'put',
data
})
}
// 删除字典项
/** 启用 / 停用字典项 */
export function toggleDictItemStatus(id, status) {
return request({
url: `${PREFIX}/items/${id}/status`,
method: 'post',
data: { status }
})
}
/** 删除字典项 */
export function deleteDictItem(id) {
return request({
url: `/platform/dict/items/${id}`,
url: `${PREFIX}/items/${id}`,
method: 'delete'
})
}
// 根据字典编码获取字典项(用于业务查询)
export function getDictItemsByCode(code, includeDisabled = false) {
/* ================= 业务读取 ================= */
/** 按字典编码取「字典类型与字典项都启用」的选项,供业务页面下拉使用 */
export function getDictOptions(code) {
return request({
url: `/platform/dict/items/code/${code}`,
method: 'get',
params: {
include_disabled: includeDisabled ? '1' : '0'
}
url: `${PREFIX}/options/${code}`,
method: 'get'
})
}
// 批量更新字典项排序
export function batchUpdateDictItemSort(data) {
/**
* 批量取多个字典的选项(一次请求),供页面/应用启动时预加载。
* 返回 data.dicts = { [dict_code]: 字典项[] },不存在的编码返回空数组。
* @param {string[]} codes 字典编码数组
*/
export function getDictOptionsBatch(codes) {
return request({
url: '/platform/dict/items/sort',
method: 'put',
data
url: `${PREFIX}/options`,
method: 'post',
data: { codes }
})
}
/** 一次取回全部「启用」字典的字典项,供登录后预热缓存 */
export function getAllDictOptions() {
return request({
url: `${PREFIX}/options`,
method: 'get'
})
}
+59
View File
@@ -0,0 +1,59 @@
import { computed } from 'vue'
import { useDictStore } from '@/stores/dict'
/**
* 字典 Composable:把「加载 + 取值 + 文案/标签色查询」收敛成一行调用。
*
* 用法:
* const { items, options, label, tagType } = useDictionary('payment_channel')
* // 模板:<el-option v-for="o in options" :label="o.label" :value="o.value" />
* // <el-tag :type="tagType(row.status)">{{ label(row.status) }}</el-tag>
*
* @param {string} code 字典编码(库里真实的 dict_code)
*/
export function useDictionary(code) {
const dictStore = useDictStore()
// setup 阶段同步触发加载:缓存命中时不发请求,首次会自动拉取并响应式刷新
dictStore.ensure(code)
/** 字典项原始数组(含 item_value / item_label / tag_type 等) */
const items = computed(() => dictStore.getDictItemsSync(code))
/** el-select / el-option 直接可用的 { label, value } 列表 */
const options = computed(() => items.value.map((it) => ({
label: it.item_label,
value: it.item_value,
tag_type: it.tag_type
})))
return {
items,
options,
label: (value, fallback) => dictStore.getDictLabel(code, value, fallback),
tagType: (value, fallback) => dictStore.getDictTagType(code, value, fallback),
find: (value) => dictStore.getDictItem(code, value)
}
}
/**
* 只要「值 → 文案」或「值 → 标签色」时的轻量用法。
* const channelLabel = useDictLabel('payment_channel')
*/
export function useDictLabel(code) {
const dictStore = useDictStore()
dictStore.ensure(code)
return (value, fallback) => dictStore.getDictLabel(code, value, fallback)
}
export function useDictTagType(code) {
const dictStore = useDictStore()
dictStore.ensure(code)
return (value, fallback) => dictStore.getDictTagType(code, value, fallback)
}
/** 预加载多个字典(页面 onMounted 里调用,一次批量请求) */
export function useDictPreload(codes) {
const dictStore = useDictStore()
return () => dictStore.preloadDicts(codes)
}
export default useDictionary
+187
View File
@@ -0,0 +1,187 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDictOptions, getDictOptionsBatch, getAllDictOptions } from '@/api/dict'
/**
* 字典 Store(平台端)
*
* 全部字典数据都来自数据库,前端不维护编码清单:
* - 登录后调用一次 loadAll(),把库里所有「启用」字典读进缓存;
* - 页面按库里真实的 dict_code 取值即可(字符串直接写,无需常量文件);
* - 之后在字典管理页新增的字典,第一次被引用时会按需自动加载。
*
* 用法:
* const dictStore = useDictStore()
* dictStore.getDictLabel('payment_order_status', row.status) // 文案
* dictStore.getDictTagType('payment_order_status', row.status) // 标签色
* dictStore.getSelectOptions('payment_channel') // 下拉选项
*/
export const useDictStore = defineStore('dict', () => {
/** dict_code -> 字典项数组(渲染依赖它,必须是响应式的) */
const dictMap = ref({})
/** 已加载过的编码(仅用于去重,不参与渲染) */
let loaded = {}
/** 正在请求中的编码 -> Promise(并发调用复用同一个请求) */
const pending = new Map()
/** 是否已完成全量预热 */
let allLoaded = false
let allPending = null
function setItems(code, list) {
dictMap.value = { ...dictMap.value, [code]: Array.isArray(list) ? list : [] }
}
function itemsOf(code) {
return dictMap.value[code] || []
}
/** 把入参规范成去重后的编码数组 */
function normalizeCodes(codes) {
return [...new Set((codes || []).map((c) => String(c || '').trim()).filter(Boolean))]
}
/**
* 加载字典(带缓存与并发去重)。
* 请求失败也会标记为已加载,避免页面反复重试;需要重试请先 clearCache。
*/
async function load(codes) {
const list = normalizeCodes(codes)
const missing = list.filter((c) => !loaded[c] && !pending.has(c))
if (missing.length) {
const promise = (async () => {
if (missing.length === 1) {
// 单个字典走专用接口
const code = missing[0]
try {
const res = await getDictOptions(code)
setItems(code, res?.code === 200 ? res.data?.list || [] : [])
} catch {
setItems(code, [])
}
} else {
// 多个字典合并成一次请求
try {
const res = await getDictOptionsBatch(missing)
const dicts = res?.code === 200 ? res.data?.dicts || {} : {}
missing.forEach((code) => setItems(code, dicts[code] || []))
} catch {
missing.forEach((code) => setItems(code, []))
}
}
})().finally(() => {
missing.forEach((code) => {
loaded[code] = true
pending.delete(code)
})
})
missing.forEach((code) => pending.set(code, promise))
}
await Promise.all(list.map((c) => pending.get(c) || Promise.resolve()))
return list.flatMap((c) => itemsOf(c))
}
/**
* 全量预热:一次请求把库里所有启用字典读进缓存(登录后调用一次即可)。
* 之后再新增的字典仍会走 load() 按需加载。
*/
function loadAll() {
if (allLoaded) return Promise.resolve()
if (allPending) return allPending
allPending = (async () => {
try {
const res = await getAllDictOptions()
const dicts = res?.code === 200 ? res.data?.dicts || {} : {}
const next = { ...dictMap.value }
Object.entries(dicts).forEach(([code, list]) => {
loaded[code] = true
next[code] = Array.isArray(list) ? list : []
})
dictMap.value = next
} catch {
// 预热失败不影响使用:后续按需加载
} finally {
allLoaded = true
allPending = null
}
})()
return allPending
}
/** 同步触发加载(不等待),用于组件 setup/onMounted 里“先渲染后填充” */
function ensure(code) {
const list = normalizeCodes([code])
if (list.some((c) => !loaded[c] && !pending.has(c))) {
load(list)
}
}
/** 同步取字典项;未加载时顺带触发加载,拿到后由响应式自动刷新 */
function getDictItemsSync(code) {
ensure(code)
return itemsOf(code)
}
/** 值 → 字典项(值按字符串比较,兼容 1 / '1' 混用) */
function getDictItem(code, value) {
if (value === undefined || value === null || value === '') return null
const key = String(value)
return itemsOf(code).find((it) => String(it.item_value) === key) || null
}
/** 值 → 文案;取不到时返回 fallback,未传则原样返回取值 */
function getDictLabel(code, value, fallback) {
const hit = getDictItem(code, value)
if (hit) return hit.item_label
if (fallback !== undefined) return fallback
return value === undefined || value === null ? '' : String(value)
}
/** 值 → el-tag 的 type;取不到时返回 fallback(默认 info) */
function getDictTagType(code, value, fallback = 'info') {
const hit = getDictItem(code, value)
return hit && hit.tag_type ? hit.tag_type : fallback
}
/** 值 → 下拉选项 { label, value },供 el-select / el-option 直接使用 */
function getSelectOptions(code) {
return getDictItemsSync(code).map((it) => ({ label: it.item_label, value: it.item_value, tag_type: it.tag_type }))
}
/** 预加载多个字典(一次批量请求) */
function preloadDicts(codes) {
return load(codes)
}
/** 清空缓存:传编码清单个,不传清全部(同时允许重新全量预热) */
function clearCache(code) {
if (code) {
delete loaded[code]
const next = { ...dictMap.value }
delete next[code]
dictMap.value = next
return
}
loaded = {}
dictMap.value = {}
allLoaded = false
}
return {
dictMap,
ensure,
loadAll,
getDictItems: load,
getDictItemsSync,
getDictItem,
getDictLabel,
getDictTagType,
getSelectOptions,
preloadDicts,
clearCache
}
})
+1
View File
@@ -53,3 +53,4 @@ export function formatDate(value: any): string {
export function formatDateOnly(value: any): string {
return formatDate(value);
}
+5
View File
@@ -2,12 +2,14 @@
import CommonAside from '@/components/CommonAside.vue';
import CommonHeader from '@/components/CommonHeader.vue';
import { useTabsStore } from '@/stores';
import { useDictStore } from '@/stores/dict';
import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted, computed } from 'vue';
import { More, Close, CircleClose, ArrowUp } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
const tabsStore = useTabsStore();
const dictStore = useDictStore();
const router = useRouter();
const route = useRoute();
const asideRef = ref(null);
@@ -39,6 +41,9 @@ function restoreTabFromRoute() {
// 组件挂载后,根据当前路由恢复 tab
onMounted(() => {
// 进入后台后把库里所有字典预热到缓存(失败不影响按需加载)
dictStore.loadAll();
// 等待路由完全加载后再恢复
nextTick(() => {
// 刷新时,localStorage 中已保存的 tabs 会在 store 初始化时恢复
@@ -1,335 +1,173 @@
<template>
<el-dialog
:title="isEditing ? '编辑字典项' : '添加字典项'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
:model="dictItemForm"
:rules="formRules"
ref="dictItemFormRef"
label-width="100px"
>
<el-form-item label="字典类型" prop="dict_type_id">
<el-input
:value="dictTypeDisplayName"
disabled
style="width: 100%"
/>
<div class="form-tip">当前字典类型:{{ dictTypeDisplayName }}</div>
</el-form-item>
<el-form-item label="字典标签" prop="dict_label">
<el-input
v-model="dictItemForm.dict_label"
placeholder="请输入字典标签(显示值)"
/>
</el-form-item>
<el-form-item label="字典值" prop="dict_value">
<el-input
v-model="dictItemForm.dict_value"
placeholder="请输入字典值(存储值)"
/>
</el-form-item>
<el-form-item label="父级字典项" prop="parent_id">
<el-select
v-model="dictItemForm.parent_id"
placeholder="选择父级字典项(可选)"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in parentDictItems"
:key="item.id"
:label="item.dict_label"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="dictItemForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="dictItemForm.sort"
:min="0"
style="width: 100%"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-input
v-model="dictItemForm.color"
placeholder="请输入颜色值(如:#FF0000 或 red)"
>
<template #append>
<el-color-picker
v-model="dictItemForm.color"
:predefine="predefineColors"
/>
</template>
</el-input>
<div class="form-tip">支持十六进制颜色值(如:#FF0000)或颜色名称(如:red)</div>
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-input
v-model="dictItemForm.icon"
placeholder="请输入图标(如:✓、✗、⭐)"
/>
<div class="form-tip">支持emoji或图标字符</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dictItemForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { addDictItem, updateDictItem, getDictItems } from '@/api/dict';
interface Props {
modelValue: boolean;
dictItem?: any;
dictTypeId?: number;
dictTypeName?: string;
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑字典项' : '新增字典项'"
width="520px"
@update:model-value="handleClose"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="字典项名称" prop="item_label">
<el-input v-model="form.item_label" placeholder="如:原材料供应商" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="字典项值" prop="item_value">
<el-input
v-model="form.item_value"
:disabled="isEdit"
placeholder="业务入库值,如:1(同一字典下不可重复,保存后不可修改)"
maxlength="100"
/>
</el-form-item>
<el-form-item label="标签色" prop="tag_type">
<el-select v-model="form.tag_type" clearable placeholder="留空则不渲染标签" style="width: 100%">
<el-option v-for="t in TAG_TYPES" :key="t" :label="t" :value="t">
<el-tag :type="t" size="small">{{ t }}</el-tag>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="默认选中" prop="is_default">
<el-radio-group v-model="form.is_default">
<el-radio :value="1">是</el-radio>
<el-radio :value="0">否</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createDictItem, updateDictItem } from '@/api/dict'
/** 与 el-tag 的 type 对齐,存入 yz_*_dict_item.tag_type */
const TAG_TYPES = ['primary', 'success', 'warning', 'danger', 'info']
const props = defineProps({
visible: { type: Boolean, default: false },
/** 所属字典ID */
dictId: { type: [Number, String], default: null },
/** 传入表示编辑,null 表示新增 */
dictItem: { type: Object, default: null }
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref()
const submitting = ref(false)
const form = reactive({
id: null,
item_label: '',
item_value: '',
tag_type: '',
sort: 0,
status: 1,
is_default: 0,
remark: ''
})
const isEdit = computed(() => !!form.id)
const rules = {
item_label: [
{ required: true, message: '请输入字典项名称', trigger: 'blur' },
{ max: 100, message: '长度不能超过 100 个字符', trigger: 'blur' }
],
item_value: [
{ required: true, message: '请输入字典项值', trigger: 'blur' },
{ max: 100, message: '长度不能超过 100 个字符', trigger: 'blur' }
]
}
watch(
() => props.visible,
(val) => {
if (val) initForm()
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dictItem: null,
dictTypeId: undefined,
dictTypeName: '',
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
success: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const submitting = ref(false);
const dictItemFormRef = ref<FormInstance>();
const parentDictItems = ref<any[]>([]);
// 预定义颜色
const predefineColors = [
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'#ff0000',
'#00ff00',
'#0000ff',
];
// 判断是否为编辑模式
const isEditing = computed(() => {
return !!(props.dictItem && props.dictItem.id);
});
// 表单数据
const dictItemForm = reactive({
id: null as number | null,
dict_type_id: 0,
dict_label: '',
dict_value: '',
parent_id: 0,
status: 1,
sort: 0,
color: '',
icon: '',
remark: '',
});
// 表单验证规则
const formRules: FormRules = {
dict_label: [
{ required: true, message: '请输入字典标签', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
dict_value: [
{ required: true, message: '请输入字典值', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
sort: [
{ required: true, message: '请输入排序', trigger: 'blur' },
{ type: 'number', min: 0, message: '排序必须大于等于 0', trigger: 'blur' },
],
color: [
{ max: 20, message: '颜色值长度不能超过 20 个字符', trigger: 'blur' },
],
icon: [
{ max: 50, message: '图标长度不能超过 50 个字符', trigger: 'blur' },
],
remark: [
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
],
};
// 获取父级字典项列表(排除自己)
async function fetchParentDictItems() {
if (!props.dictTypeId) return;
try {
const res = await getDictItems({ dict_type_id: props.dictTypeId });
if (res.success && res.data) {
// 如果是编辑模式,排除当前字典项
if (isEditing.value && props.dictItem) {
parentDictItems.value = res.data.filter(
(item: any) => item.id !== props.dictItem.id
);
} else {
parentDictItems.value = res.data;
}
}
} catch (error) {
console.error('获取父级字典项失败:', error);
}
)
function initForm() {
const d = props.dictItem
form.id = d?.id || null
form.item_label = d?.item_label || ''
form.item_value = d?.item_value || ''
form.tag_type = d?.tag_type || ''
form.sort = d?.sort ?? 0
form.status = d?.status ?? 1
form.is_default = d?.is_default ?? 0
form.remark = d?.remark || ''
formRef.value?.clearValidate()
}
function handleClose() {
emit('update:visible', false)
}
function handleClosed() {
formRef.value?.resetFields()
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
// 计算字典类型显示名称
const dictTypeDisplayName = computed(() => {
if (props.dictTypeName) {
return props.dictTypeName;
}
if (props.dictTypeId) {
return `字典类型 ID: ${props.dictTypeId}`;
}
return '未知字典类型';
});
// 监听 dictItem 变化,填充表单数据
watch(
() => props.dictItem,
(newDictItem) => {
if (newDictItem) {
dictItemForm.id = newDictItem.id || null;
dictItemForm.dict_type_id = newDictItem.dict_type_id || props.dictTypeId || 0;
dictItemForm.dict_label = newDictItem.dict_label || '';
dictItemForm.dict_value = newDictItem.dict_value || '';
dictItemForm.parent_id = newDictItem.parent_id || 0;
dictItemForm.status = newDictItem.status !== undefined ? newDictItem.status : 1;
dictItemForm.sort = newDictItem.sort || 0;
dictItemForm.color = newDictItem.color || '';
dictItemForm.icon = newDictItem.icon || '';
dictItemForm.remark = newDictItem.remark || '';
} else {
resetForm();
}
},
{ immediate: true }
);
// 监听弹窗打开,获取父级字典项
watch(visible, (val) => {
if (val) {
if (props.dictTypeId) {
dictItemForm.dict_type_id = props.dictTypeId;
}
fetchParentDictItems();
}
});
// 重置表单
function resetForm() {
dictItemForm.id = null;
dictItemForm.dict_type_id = props.dictTypeId || 0;
dictItemForm.dict_label = '';
dictItemForm.dict_value = '';
dictItemForm.parent_id = 0;
dictItemForm.status = 1;
dictItemForm.sort = 0;
dictItemForm.color = '';
dictItemForm.icon = '';
dictItemForm.remark = '';
dictItemFormRef.value?.clearValidate();
if (!isEdit.value && !props.dictId) {
ElMessage.error('未选择字典')
return
}
// 关闭对话框
function handleClose() {
visible.value = false;
resetForm();
const payload = {
item_label: form.item_label,
tag_type: form.tag_type || '',
sort: form.sort,
status: form.status,
is_default: form.is_default,
remark: form.remark
}
// 提交表单
async function handleSubmit() {
if (!dictItemFormRef.value) return;
try {
await dictItemFormRef.value.validate();
} catch (error) {
return;
}
if (!dictItemForm.dict_type_id) {
ElMessage.error('字典类型ID不能为空');
return;
}
submitting.value = true;
try {
const submitData: any = {
dict_type_id: dictItemForm.dict_type_id,
dict_label: dictItemForm.dict_label,
dict_value: dictItemForm.dict_value,
parent_id: dictItemForm.parent_id || 0,
status: dictItemForm.status,
sort: dictItemForm.sort,
color: dictItemForm.color || '',
icon: dictItemForm.icon || '',
remark: dictItemForm.remark || '',
};
if (isEditing.value) {
await updateDictItem(dictItemForm.id!, submitData);
ElMessage.success('字典项更新成功');
} else {
await addDictItem(submitData);
ElMessage.success('字典项添加成功');
}
emit('success');
handleClose();
} catch (error: any) {
ElMessage.error(error.message || '操作失败,请重试');
} finally {
submitting.value = false;
}
if (isEdit.value) {
// 字典项值不可改,避免历史数据里的存量值对不上
payload.id = form.id
} else {
payload.dict_id = props.dictId
payload.item_value = form.item_value
}
</script>
<style scoped lang="less">
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
submitting.value = true
try {
const res = isEdit.value
? await updateDictItem(form.id, payload)
: await createDictItem(payload)
if (res.code === 200) {
ElMessage.success(isEdit.value ? '保存成功' : '新增成功')
emit('update:visible', false)
emit('success', res.data || null)
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error(e?.response?.data?.msg || e?.message || '保存失败')
} finally {
submitting.value = false
}
</style>
}
</script>
@@ -1,316 +0,0 @@
<template>
<el-dialog
:title="isEditing ? '编辑字典项' : '添加字典项'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
:model="dictItemForm"
:rules="formRules"
ref="dictItemFormRef"
label-width="100px"
>
<el-form-item label="字典类型" prop="dict_type_id">
<el-select
v-model="dictItemForm.dict_type_id"
placeholder="请选择字典类型"
filterable
style="width: 100%"
:disabled="isEditing || !!dictTypeId"
>
<el-option
v-for="type in dictTypes"
:key="type.id"
:label="`${type.dict_name} (${type.dict_code})`"
:value="type.id"
/>
</el-select>
</el-form-item>
<el-form-item label="字典标签" prop="dict_label">
<el-input
v-model="dictItemForm.dict_label"
placeholder="请输入字典标签(显示值)"
/>
</el-form-item>
<el-form-item label="字典值" prop="dict_value">
<el-input
v-model="dictItemForm.dict_value"
placeholder="请输入字典值(存储值)"
/>
</el-form-item>
<!-- <el-form-item label="父级ID" prop="parent_id">
<el-input-number
v-model="dictItemForm.parent_id"
:min="0"
style="width: 100%"
placeholder="0表示顶级"
/>
<div class="form-tip">0表示顶级字典项</div>
</el-form-item> -->
<el-form-item label="状态" prop="status">
<el-radio-group v-model="dictItemForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序序号" prop="sort">
<el-input-number
v-model="dictItemForm.sort"
:min="0"
style="width: 100%"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="颜色标记" prop="color">
<el-input
v-model="dictItemForm.color"
placeholder="请输入颜色值(如:#FF0000 或 red)"
maxlength="20"
/>
<div class="form-tip">用于前端显示时的颜色标记</div>
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-input
v-model="dictItemForm.icon"
placeholder="请输入图标名称或类名"
maxlength="50"
/>
<div class="form-tip">用于前端显示时的图标</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dictItemForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { getDictTypes, addDictItem, updateDictItem } from '@/api/dict'
interface Props {
modelValue: boolean
dictItem?: any
dictTypeId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dictItem: null,
dictTypeId: null,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const submitting = ref(false)
const dictItemFormRef = ref<FormInstance>()
const dictTypes = ref<any[]>([])
// 判断是否为编辑模式
const isEditing = computed(() => {
return !!(props.dictItem && props.dictItem.id)
})
// 表单数据
const dictItemForm = reactive({
id: null as number | null,
dict_type_id: null as number | null,
dict_label: '',
dict_value: '',
parent_id: 0,
status: 1,
sort: 0,
color: '',
icon: '',
remark: '',
})
// 表单验证规则
const formRules: FormRules = {
dict_type_id: [
{ required: true, message: '请选择字典类型', trigger: 'change' },
],
dict_label: [
{ required: true, message: '请输入字典标签', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
dict_value: [
{ required: true, message: '请输入字典值', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
parent_id: [
{ type: 'number', min: 0, message: '父级ID必须大于等于 0', trigger: 'blur' },
],
sort: [
{ type: 'number', min: 0, message: '排序序号必须大于等于 0', trigger: 'blur' },
],
color: [
{ max: 20, message: '颜色值长度不能超过 20 个字符', trigger: 'blur' },
],
icon: [
{ max: 50, message: '图标长度不能超过 50 个字符', trigger: 'blur' },
],
remark: [
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
],
}
// 获取字典类型列表
async function fetchDictTypes() {
try {
const res = await getDictTypes()
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictTypes.value = res
} else if (res && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
}
} catch (err: any) {
console.error('获取字典类型列表失败:', err)
}
}
// 监听 dictItem 变化,填充表单数据
watch(
() => props.dictItem,
(newDictItem) => {
if (newDictItem) {
dictItemForm.id = newDictItem.id || null
dictItemForm.dict_type_id = newDictItem.dict_type_id || null
dictItemForm.dict_label = newDictItem.dict_label || ''
dictItemForm.dict_value = newDictItem.dict_value || ''
dictItemForm.parent_id = newDictItem.parent_id || 0
dictItemForm.status = newDictItem.status !== undefined ? newDictItem.status : 1
dictItemForm.sort = newDictItem.sort || 0
dictItemForm.color = newDictItem.color || ''
dictItemForm.icon = newDictItem.icon || ''
dictItemForm.remark = newDictItem.remark || ''
} else {
resetForm()
}
},
{ immediate: true }
)
// 监听 dictTypeId 变化
watch(
() => props.dictTypeId,
(newTypeId) => {
if (newTypeId && !isEditing.value) {
dictItemForm.dict_type_id = newTypeId
}
},
{ immediate: true }
)
// 重置表单
function resetForm() {
dictItemForm.id = null
dictItemForm.dict_type_id = props.dictTypeId || null
dictItemForm.dict_label = ''
dictItemForm.dict_value = ''
dictItemForm.parent_id = 0
dictItemForm.status = 1
dictItemForm.sort = 0
dictItemForm.color = ''
dictItemForm.icon = ''
dictItemForm.remark = ''
dictItemFormRef.value?.clearValidate()
}
// 关闭对话框
function handleClose() {
visible.value = false
resetForm()
}
// 提交表单
async function handleSubmit() {
if (!dictItemFormRef.value) return
try {
await dictItemFormRef.value.validate()
} catch (error) {
return
}
submitting.value = true
try {
const submitData: any = {
dict_type_id: dictItemForm.dict_type_id,
dict_label: dictItemForm.dict_label,
dict_value: dictItemForm.dict_value,
parent_id: dictItemForm.parent_id,
status: dictItemForm.status,
sort: dictItemForm.sort,
color: dictItemForm.color || '',
icon: dictItemForm.icon || '',
remark: dictItemForm.remark || '',
}
if (isEditing.value) {
const res = await updateDictItem(dictItemForm.id!, submitData)
if (res.success === true || res.code === 0) {
ElMessage.success('字典项更新成功')
emit('success')
handleClose()
} else {
ElMessage.error(res.message || '更新失败,请重试')
}
} else {
const res = await addDictItem(submitData)
if (res.success === true || res.code === 0) {
ElMessage.success('字典项添加成功')
emit('success')
handleClose()
} else {
ElMessage.error(res.message || '添加失败,请重试')
}
}
} catch (error: any) {
ElMessage.error(error.message || '操作失败,请重试')
} finally {
submitting.value = false
}
}
onMounted(() => {
fetchDictTypes()
})
</script>
<style scoped lang="less">
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
}
</style>
@@ -1,422 +0,0 @@
<template>
<div class="dict-item-list">
<div class="toolbar">
<div class="toolbar-left">
<el-select
v-model="selectedDictTypeId"
placeholder="选择字典类型"
clearable
filterable
style="width: 250px"
@change="handleTypeChange"
>
<el-option
v-for="type in dictTypes"
:key="type.id"
:label="`${type.dict_name} (${type.dict_code})`"
:value="type.id"
/>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="搜索字典项标签或值"
clearable
style="width: 300px"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="statusFilter"
placeholder="状态筛选"
clearable
style="width: 120px"
@change="handleFilter"
>
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</div>
<div class="toolbar-right">
<el-button
type="primary"
:disabled="!selectedDictTypeId"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>
添加字典项
</el-button>
</div>
</div>
<!-- 提示信息 -->
<el-alert
v-if="!selectedDictTypeId"
title="请先选择字典类型"
type="info"
:closable="false"
style="margin-bottom: 16px"
/>
<!-- 错误状态 -->
<div v-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchDictItems">重试</el-button>
</div>
<!-- 字典项列表 -->
<div v-else-if="selectedDictTypeId">
<el-table
:data="filteredDictItems"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="dict_label" label="字典标签" min-width="150" align="center" />
<el-table-column prop="dict_value" label="字典值" min-width="150" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.dict_value }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
<template #default="{ row }">
<span>{{ row.parent_id || '-' }}</span>
</template>
</el-table-column> -->
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="color" label="颜色" width="100" align="center">
<template #default="{ row }">
<div v-if="row.color" class="color-display">
<span
class="color-dot"
:style="{ backgroundColor: row.color }"
></span>
<span>{{ row.color }}</span>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="100" align="center">
<template #default="{ row }">
<span v-if="row.icon">{{ row.icon }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" align="center" show-overflow-tooltip />
<el-table-column label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatDate(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 编辑对话框 -->
<DictItemEditDialog
v-model="editDialogVisible"
:dict-item="currentDictItem"
:dict-type-id="selectedDictTypeId"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
ElMessage,
ElMessageBox,
} from 'element-plus'
import { Plus, Search, Edit, Delete } from '@element-plus/icons-vue'
import {
getDictTypes,
getDictItems,
deleteDictItem,
} from '@/api/dict'
import DictItemEditDialog from './DictItemEditDialog.vue'
interface Props {
selectedTypeId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
selectedTypeId: null,
})
const emit = defineEmits<{
'refresh': []
}>()
const dictTypes = ref<any[]>([])
const dictItems = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const searchKeyword = ref('')
const statusFilter = ref<number | ''>('')
const selectedDictTypeId = ref<number | null>(null)
const editDialogVisible = ref(false)
const currentDictItem = ref<any>(null)
// 过滤后的字典项
const filteredDictItems = computed(() => {
let result = dictItems.value
// 搜索过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(
(item) =>
(item.dict_label && item.dict_label.toLowerCase().includes(keyword)) ||
(item.dict_value && item.dict_value.toLowerCase().includes(keyword)) ||
(item.remark && item.remark.toLowerCase().includes(keyword))
)
}
// 状态过滤
if (statusFilter.value !== '') {
result = result.filter((item) => item.status === statusFilter.value)
}
return result
})
// 监听外部传入的selectedTypeId
watch(
() => props.selectedTypeId,
(newId) => {
if (newId) {
selectedDictTypeId.value = newId
fetchDictItems()
}
},
{ immediate: true }
)
// 格式化日期
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return dateStr as string
}
}
// 获取字典类型列表(用于下拉选择)
async function fetchDictTypes() {
try {
const res = await getDictTypes()
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictTypes.value = res
} else if (res && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
}
} catch (err: any) {
console.error('获取字典类型列表失败:', err)
}
}
// 获取字典项列表
async function fetchDictItems() {
if (!selectedDictTypeId.value) {
dictItems.value = []
return
}
loading.value = true
error.value = ''
try {
const res = await getDictItems({ dict_type_id: selectedDictTypeId.value })
if (res.success === true && res.data) {
dictItems.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictItems.value = res
} else if (res && res.data) {
dictItems.value = Array.isArray(res.data) ? res.data : []
} else {
error.value = res.message || '获取字典项列表失败'
dictItems.value = []
}
} catch (err: any) {
error.value = err.message || '获取字典项列表失败'
dictItems.value = []
} finally {
loading.value = false
}
}
// 字典类型改变
const handleTypeChange = () => {
fetchDictItems()
}
// 搜索
const handleSearch = () => {
// 搜索逻辑在computed中处理
}
// 筛选
const handleFilter = () => {
// 筛选逻辑在computed中处理
}
// 设置字典类型ID(供外部调用)
function setTypeId(typeId: number) {
selectedDictTypeId.value = typeId
}
// 添加字典项
function handleAdd() {
if (!selectedDictTypeId.value) {
ElMessage.warning('请先选择字典类型')
return
}
currentDictItem.value = null
editDialogVisible.value = true
}
// 编辑字典项
function handleEdit(row: any) {
currentDictItem.value = { ...row }
editDialogVisible.value = true
}
// 删除字典项
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除字典项「${row.dict_label}」吗?删除后不可恢复。`,
'警告',
{ type: 'warning' }
)
loading.value = true
try {
const res = await deleteDictItem(row.id)
if (res.success === true || res.code === 0) {
ElMessage.success('删除成功')
fetchDictItems()
emit('refresh')
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (err: any) {
ElMessage.error(err.message || '删除失败')
} finally {
loading.value = false
}
} catch {
// 取消删除
}
}
// 编辑成功回调
function handleEditSuccess() {
fetchDictItems()
emit('refresh')
}
// 刷新
function refresh() {
fetchDictTypes()
if (selectedDictTypeId.value) {
fetchDictItems()
}
}
defineExpose({
refresh,
setTypeId,
})
onMounted(() => {
fetchDictTypes()
if (selectedDictTypeId.value) {
fetchDictItems()
}
})
</script>
<style scoped lang="less">
.dict-item-list {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
.toolbar-left {
display: flex;
gap: 12px;
align-items: center;
}
.toolbar-right {
display: flex;
gap: 12px;
}
}
.color-display {
display: flex;
align-items: center;
gap: 6px;
justify-content: center;
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #ddd;
}
}
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px 0 16px 0;
background: var(--el-bg-color-page);
border-radius: 5px;
gap: 16px;
}
</style>
@@ -1,241 +1,145 @@
<template>
<el-dialog
:title="isEditing ? '编辑字典类型' : '添加字典类型'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
:model="dictTypeForm"
:rules="formRules"
ref="dictTypeFormRef"
label-width="100px"
>
<el-form-item label="字典编码" prop="dict_code">
<el-input
v-model="dictTypeForm.dict_code"
placeholder="请输入字典编码(如:user_status)"
:disabled="isEditing"
/>
<div class="form-tip">只能包含字母、数字或下划线,且必须唯一</div>
</el-form-item>
<el-form-item label="字典名称" prop="dict_name">
<el-input
v-model="dictTypeForm.dict_name"
placeholder="请输入字典名称"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="dictTypeForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="shouldShowGlobalOption" label="全局展示" prop="is_global">
<el-radio-group v-model="dictTypeForm.is_global">
<el-radio :value="1">是</el-radio>
<el-radio :value="0">否</el-radio>
</el-radio-group>
<div class="form-tip">是:所有租户可见;否:仅租户内部可见</div>
</el-form-item>
<el-form-item label="排序序号" prop="sort">
<el-input-number
v-model="dictTypeForm.sort"
:min="0"
style="width: 100%"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dictTypeForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { addDictType, updateDictType, getDictTypes } from '@/api/dict';
import { useAuthStore } from '@/stores/auth';
interface Props {
modelValue: boolean;
dictType?: any;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dictType: null,
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
success: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const submitting = ref(false);
const dictTypeFormRef = ref<FormInstance>();
const authStore = useAuthStore();
// 已移除父级选择
// 判断是否为编辑模式
const isEditing = computed(() => {
return !!(props.dictType && props.dictType.id);
});
// 判断是否应该显示全局展示选项(只有 system_admin 和 admin 角色才能看到)
const shouldShowGlobalOption = computed(() => {
const roleCode = authStore.user.role_code;
return roleCode === 'system_admin' || roleCode === 'admin';
});
// 表单数据
const dictTypeForm = reactive({
id: null as number | null,
dict_code: '',
dict_name: '',
status: 1,
is_global: 0,
sort: 0,
remark: '',
});
// 表单验证规则
const formRules: FormRules = {
dict_code: [
{ required: true, message: '请输入字典编码', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: '只能包含字母、数字或下划线',
trigger: 'blur',
},
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
dict_name: [
{ required: true, message: '请输入字典名称', trigger: 'blur' },
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' },
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
sort: [
{ required: true, message: '请输入排序序号', trigger: 'blur' },
{ type: 'number', min: 0, message: '排序序号必须大于等于 0', trigger: 'blur' },
],
remark: [
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
],
};
// 已移除父级选择相关数据获取
// 监听 dictType 变化,填充表单数据
watch(
() => props.dictType,
(newDictType) => {
if (newDictType) {
dictTypeForm.id = newDictType.id || null;
dictTypeForm.dict_code = newDictType.dict_code || '';
dictTypeForm.dict_name = newDictType.dict_name || '';
dictTypeForm.status = newDictType.status !== undefined ? newDictType.status : 1;
dictTypeForm.is_global = newDictType.is_global !== undefined ? newDictType.is_global : 0;
dictTypeForm.sort = newDictType.sort || 0;
dictTypeForm.remark = newDictType.remark || '';
} else {
resetForm();
}
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑字典' : '新增字典'"
width="520px"
@update:model-value="handleClose"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="字典名称" prop="dict_name">
<el-input v-model="form.dict_name" placeholder="如:供应商类型" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="字典编码" prop="dict_code">
<el-input
v-model="form.dict_code"
:disabled="isEdit"
placeholder="如:supplier_type(业务按编码取选项,保存后不可修改)"
maxlength="100"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createDictType, updateDictType } from '@/api/dict'
const props = defineProps({
visible: { type: Boolean, default: false },
/** 传入表示编辑,null 表示新增 */
dictType: { type: Object, default: null }
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref()
const submitting = ref(false)
const form = reactive({
id: null,
dict_name: '',
dict_code: '',
sort: 0,
status: 1,
remark: ''
})
const isEdit = computed(() => !!form.id)
const rules = {
dict_name: [
{ required: true, message: '请输入字典名称', trigger: 'blur' },
{ max: 100, message: '长度不能超过 100 个字符', trigger: 'blur' }
],
dict_code: [
{ required: true, message: '请输入字典编码', trigger: 'blur' },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: '编码以字母开头,只能包含字母、数字与下划线',
trigger: 'blur'
},
{ immediate: true }
);
// 已移除父级选择监听
// 重置表单
function resetForm() {
dictTypeForm.id = null;
dictTypeForm.dict_code = '';
dictTypeForm.dict_name = '';
dictTypeForm.status = 1;
dictTypeForm.is_global = 0;
dictTypeForm.sort = 0;
dictTypeForm.remark = '';
dictTypeFormRef.value?.clearValidate();
{ max: 100, message: '长度不能超过 100 个字符', trigger: 'blur' }
]
}
watch(
() => props.visible,
(val) => {
if (val) initForm()
}
// 关闭对话框
function handleClose() {
visible.value = false;
resetForm();
)
function initForm() {
const d = props.dictType
form.id = d?.id || null
form.dict_name = d?.dict_name || ''
form.dict_code = d?.dict_code || ''
form.sort = d?.sort ?? 0
form.status = d?.status ?? 1
form.remark = d?.remark || ''
formRef.value?.clearValidate()
}
function handleClose() {
emit('update:visible', false)
}
function handleClosed() {
formRef.value?.resetFields()
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
// 提交表单
async function handleSubmit() {
if (!dictTypeFormRef.value) return;
try {
await dictTypeFormRef.value.validate();
} catch (error) {
return;
}
submitting.value = true;
try {
const submitData: any = {
dict_code: dictTypeForm.dict_code,
dict_name: dictTypeForm.dict_name,
status: dictTypeForm.status,
is_global: dictTypeForm.is_global,
sort: dictTypeForm.sort,
remark: dictTypeForm.remark || '',
};
if (isEditing.value) {
await updateDictType(dictTypeForm.id!, submitData);
ElMessage.success('字典类型更新成功');
} else {
await addDictType(submitData);
ElMessage.success('字典类型添加成功');
}
emit('success');
handleClose();
} catch (error: any) {
ElMessage.error(error.message || '操作失败,请重试');
} finally {
submitting.value = false;
const payload = {
dict_name: form.dict_name,
sort: form.sort,
status: form.status,
remark: form.remark
}
// 新增时才提交编码;编辑时编码不可改,由后端忽略
if (!isEdit.value) payload.dict_code = form.dict_code
submitting.value = true
try {
const res = isEdit.value
? await updateDictType(form.id, payload)
: await createDictType(payload)
if (res.code === 200) {
ElMessage.success(isEdit.value ? '保存成功' : '新增成功')
emit('update:visible', false)
emit('success', isEdit.value ? { id: form.id } : res.data || null)
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error(e?.response?.data?.msg || e?.message || '保存失败')
} finally {
submitting.value = false
}
onMounted(() => {
// 无需加载父级数据
});
</script>
<style scoped lang="less">
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
}
</style>
}
</script>
@@ -1,316 +0,0 @@
<template>
<div class="dict-type-list">
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="searchKeyword"
placeholder="搜索字典类型名称或编码"
clearable
style="width: 300px"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="statusFilter"
placeholder="状态筛选"
clearable
style="width: 120px"
@change="handleFilter"
>
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加字典类型
</el-button>
</div>
</div>
<!-- 错误状态 -->
<div v-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchDictTypes">重试</el-button>
</div>
<!-- 字典类型列表 -->
<div v-else>
<el-table
:data="filteredDictTypes"
stripe
style="width: 100%"
v-loading="loading"
@row-click="handleRowClick"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="dict_name" label="字典名称" min-width="150" align="center" />
<el-table-column prop="dict_code" label="字典编码" min-width="150" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.dict_code }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tid" label="租户ID" width="100" align="center">
<template #default="{ row }">
<span>{{ row.tid || 0 }}</span>
</template>
</el-table-column>
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
<template #default="{ row }">
<span>{{ row.parent_id || '-' }}</span>
</template>
</el-table-column> -->
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="remark" label="备注" min-width="200" align="center" show-overflow-tooltip />
<el-table-column label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatDate(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click.stop="handleViewItems(row)">
<el-icon><Menu /></el-icon>
查看字典项
</el-button>
<el-button size="small" type="primary" link @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 编辑对话框 -->
<DictTypeEdit
v-model="editDialogVisible"
:dict-type="currentDictType"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
ElMessage,
ElMessageBox,
} from 'element-plus'
import { Plus, Search, Edit, Delete, Menu } from '@element-plus/icons-vue'
import {
getDictTypes,
deleteDictType,
} from '@/api/dict'
import DictTypeEdit from './DictTypeEdit.vue'
const emit = defineEmits<{
'select-type': [typeId: number]
'refresh': []
}>()
const dictTypes = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const searchKeyword = ref('')
const statusFilter = ref<number | ''>('')
const editDialogVisible = ref(false)
const currentDictType = ref<any>(null)
// 过滤后的字典类型
const filteredDictTypes = computed(() => {
let result = dictTypes.value
// 搜索过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(
(type) =>
(type.dict_name && type.dict_name.toLowerCase().includes(keyword)) ||
(type.dict_code && type.dict_code.toLowerCase().includes(keyword))
)
}
// 状态过滤
if (statusFilter.value !== '') {
result = result.filter((type) => type.status === statusFilter.value)
}
return result
})
// 格式化日期
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return dateStr as string
}
}
// 获取字典类型列表
async function fetchDictTypes() {
loading.value = true
error.value = ''
try {
const res = await getDictTypes()
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictTypes.value = res
} else if (res && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else {
error.value = res.message || '获取字典类型列表失败'
dictTypes.value = []
}
} catch (err: any) {
error.value = err.message || '获取字典类型列表失败'
dictTypes.value = []
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
// 搜索逻辑在computed中处理
}
// 筛选
const handleFilter = () => {
// 筛选逻辑在computed中处理
}
// 添加字典类型
function handleAdd() {
currentDictType.value = null
editDialogVisible.value = true
}
// 编辑字典类型
function handleEdit(row: any) {
currentDictType.value = { ...row }
editDialogVisible.value = true
}
// 查看字典项
function handleViewItems(row: any) {
emit('select-type', row.id)
}
// 行点击
function handleRowClick(row: any) {
// 可以在这里处理行点击事件
}
// 删除字典类型
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除字典类型「${row.dict_name}」吗?删除后不可恢复。`,
'警告',
{ type: 'warning' }
)
loading.value = true
try {
const res = await deleteDictType(row.id)
if (res.success === true || res.code === 0) {
ElMessage.success('删除成功')
fetchDictTypes()
emit('refresh')
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (err: any) {
ElMessage.error(err.message || '删除失败')
} finally {
loading.value = false
}
} catch {
// 取消删除
}
}
// 编辑成功回调
function handleEditSuccess() {
fetchDictTypes()
emit('refresh')
}
// 刷新
function refresh() {
fetchDictTypes()
}
defineExpose({
refresh,
})
onMounted(() => {
fetchDictTypes()
})
</script>
<style scoped lang="less">
.dict-type-list {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
.toolbar-left {
display: flex;
gap: 12px;
align-items: center;
}
.toolbar-right {
display: flex;
gap: 12px;
}
}
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px 0 16px 0;
background: var(--el-bg-color-page);
border-radius: 5px;
gap: 16px;
}
</style>
+426 -277
View File
@@ -1,368 +1,517 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>字典管理</h2>
<div class="header-actions">
<el-button @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 内容区域:根据当前视图切换 -->
<div v-if="currentView === 'types'">
<div class="tab-content">
<div class="toolbar">
<el-button type="primary" @click="handleAddType">
<el-icon><Plus /></el-icon>
添加字典类型
</el-button>
<div class="search-box">
<el-input
v-model="typeSearchKeyword"
placeholder="搜索字典类型名称或编码"
clearable
style="width: 300px"
@input="handleTypeSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<el-table
:data="dictTypes"
stripe
style="width: 100%"
v-loading="typeLoading"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="dict_name" label="字典名称" min-width="150" align="center" />
<el-table-column prop="dict_code" label="字典编码" min-width="150" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.dict_code }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
<template #default="{ row }">
<span v-if="row.parent_id > 0">{{ row.parent_id }}</span>
<span v-else style="color: #999">-</span>
</template>
</el-table-column> -->
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_global" label="全局展示" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.is_global === 1 ? 'success' : 'warning'" size="small">
{{ row.is_global === 1 ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="sort" label="排序" width="80" align="center" /> -->
<!-- <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip /> -->
<el-table-column prop="create_time" label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatDate(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEditType(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="primary" link @click="handleViewItems(row)">
<el-icon><List /></el-icon>
查看字典项
</el-button>
<el-button size="small" type="danger" link @click="handleDeleteType(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:total="totalCount"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
</div>
<div v-else-if="currentView === 'items'">
<div class="tab-content">
<div class="toolbar">
<div>
<el-button @click="goBackToTypes">
返回
</el-button>
</div>
<div class="search-box">
<span v-if="selectedDictType">
当前字典类型:{{ selectedDictType.dict_name }}({{ selectedDictType.dict_code }})
</span>
</div>
<div class="dict-page">
<!-- ================= 一级:字典列表 ================= -->
<template v-if="view === 'list'">
<div class="page-header">
<div>
<h2>数据字典</h2>
<p>维护系统下拉选项:先建字典,再进「字典内容」维护字典项。</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Plus" @click="openTypeCreate">新增字典</el-button>
<el-button :icon="Refresh" @click="loadTypes">刷新</el-button>
</div>
<DictItemList :selected-type-id="selectedDictTypeId || undefined" @refresh="refreshTypesSilently" />
</div>
</div>
<!-- 字典类型编辑对话框 -->
<DictTypeEdit
v-model="typeDialogVisible"
:dict-type="currentDictType"
@success="handleTypeSuccess"
<div class="dict-panel">
<div class="panel-header">
<el-input
v-model="typeKeyword"
class="panel-search"
placeholder="搜索字典名称 / 编码"
clearable
:prefix-icon="Search"
@keyup.enter="handleTypeSearch"
@clear="handleTypeSearch"
/>
</div>
<el-table
:data="types"
class="dict-table"
stripe
v-loading="typeLoading"
>
<el-table-column prop="dict_name" label="字典名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.dict_name }}</span>
<!-- 全局内置字典:所有租户共享、只读(平台端无此字段,自动不显示) -->
<el-tag v-if="row.is_global" class="inline-tag" size="small" type="warning" effect="plain">
内置
</el-tag>
</template>
</el-table-column>
<el-table-column prop="dict_code" label="字典编码" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.dict_code }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="说明" min-width="220" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="175" align="center">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openItems(row)">字典内容</el-button>
<el-button link type="primary" size="small" :disabled="row.is_global" @click="openTypeEdit(row)">
编辑
</el-button>
<el-button link type="danger" size="small" :disabled="row.is_global" @click="handleTypeDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="panel-pagination">
<el-pagination
v-model:current-page="typePage.page"
v-model:page-size="typePage.pageSize"
:total="typePage.total"
:page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadTypes"
@current-change="loadTypes"
/>
</div>
</div>
</template>
<!-- ================= 二级:字典项列表 ================= -->
<template v-else>
<div class="page-header">
<div>
<el-breadcrumb separator="/">
<el-breadcrumb-item>
<a class="crumb-link" @click="goBackToList">数据字典</a>
</el-breadcrumb-item>
<el-breadcrumb-item>字典项</el-breadcrumb-item>
</el-breadcrumb>
<h2 class="sub-title">
{{ currentType?.dict_name }}
<el-tag size="small" effect="plain">{{ currentType?.dict_code }}</el-tag>
<el-tag v-if="isGlobalDict" size="small" type="warning" effect="plain">内置只读</el-tag>
</h2>
</div>
<div class="header-actions">
<el-button :icon="ArrowLeft" @click="goBackToList">返回</el-button>
<el-button type="primary" :icon="Plus" :disabled="isGlobalDict" @click="openItemCreate">
新增字典项
</el-button>
</div>
</div>
<div class="dict-panel">
<div class="panel-header">
<el-input
v-model="itemKeyword"
class="panel-search"
placeholder="搜索字典项名称 / 值"
clearable
:prefix-icon="Search"
@keyup.enter="handleItemSearch"
@clear="handleItemSearch"
/>
</div>
<el-table
:data="items"
class="dict-table"
stripe
border
v-loading="itemLoading"
>
<el-table-column prop="item_label" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="item_value" label="值" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.item_value }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tag_type" label="标签色" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.tag_type" :type="row.tag_type" size="small">{{ row.tag_type }}</el-tag>
<span v-else class="muted">-</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="is_default" label="默认" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_default === 1" type="success" size="small">默认</el-tag>
<span v-else class="muted">-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :disabled="isGlobalDict" @click="openItemEdit(row)">
编辑
</el-button>
<el-button link type="primary" size="small" :disabled="isGlobalDict" @click="handleItemToggle(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button link type="danger" size="small" :disabled="isGlobalDict" @click="handleItemDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="panel-pagination">
<el-pagination
v-model:current-page="itemPage.page"
v-model:page-size="itemPage.pageSize"
:total="itemPage.total"
:page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadItems"
@current-change="loadItems"
/>
</div>
</div>
</template>
<DictTypeEdit v-model="typeDialogVisible" :dict-type="currentDictType" @success="handleTypeSaved" />
<DictItemEdit
v-model="itemDialogVisible"
:dict-id="currentType?.id"
:dict-item="currentDictItem"
@success="loadItems"
/>
<!-- 字典项独立页面内操作通过 DictItemList 完成 -->
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
<script setup>
import { computed, ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Edit,
Delete,
Refresh,
Search,
List,
} from '@element-plus/icons-vue'
import { Plus, Search, Refresh, ArrowLeft } from '@element-plus/icons-vue'
import {
getDictTypes,
deleteDictType,
getDictItems,
deleteDictItem,
toggleDictItemStatus
} from '@/api/dict'
import DictTypeEdit from './components/DictTypeEdit.vue'
import DictItemList from './components/DictItemList.vue'
import DictItemEdit from './components/DictItemEdit.vue'
import { formatDateTime } from '@/utils/datetime'
/** 当前视图:list 字典列表 / items 字典项列表 */
const view = ref('list')
/* ---------------- 字典列表 ---------------- */
const currentView = ref<'types' | 'items'>('types')
const typeLoading = ref(false)
// 字典类型相关
const dictTypes = ref<any[]>([])
const typeSearchKeyword = ref('')
const types = ref([])
const typeKeyword = ref('')
const typeDialogVisible = ref(false)
const currentDictType = ref<any>(null)
const currentDictType = ref(null)
const typePage = reactive({ page: 1, pageSize: 15, total: 0 })
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
/* ---------------- 字典项 ---------------- */
const selectedDictTypeId = ref<number | null>(null)
const selectedDictType = computed(() => {
if (!selectedDictTypeId.value) {
return null
}
return dictTypes.value.find((type) => type.id === selectedDictTypeId.value) || null
})
const itemLoading = ref(false)
const items = ref([])
const itemKeyword = ref('')
const itemDialogVisible = ref(false)
const currentDictItem = ref(null)
const itemPage = reactive({ page: 1, pageSize: 15, total: 0 })
// 格式化日期
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return dateStr as string
}
/** 进入字典项页时选中的字典 */
const currentType = ref(null)
/** 后端失败时优先透出业务提示(兼容 {code,msg} 与 HTTP 错误两种形态) */
function errMsg(e, fallback) {
return e?.response?.data?.msg || e?.message || fallback
}
// 获取字典类型列表
async function fetchDictTypes() {
/** 全局内置字典:只读(平台端字段为 undefined,自然放开) */
const isGlobalDict = computed(() => !!currentType.value?.is_global)
onMounted(loadTypes)
async function loadTypes() {
typeLoading.value = true
try {
const res = await getDictTypes({
page: currentPage.value,
pageSize: pageSize.value,
keyword: typeSearchKeyword.value
page: typePage.page,
pageSize: typePage.pageSize,
keyword: typeKeyword.value
})
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data.list) ? res.data.list : (Array.isArray(res.data) ? res.data : [])
totalCount.value = res.data.total || res.data.length || 0
} else if (Array.isArray(res)) {
dictTypes.value = res
totalCount.value = res.length
if (res.code === 200) {
types.value = res.data?.list || []
typePage.total = res.data?.total || 0
} else {
dictTypes.value = []
totalCount.value = 0
ElMessage.error(res.message || '获取字典类型失败')
ElMessage.error(res.msg || '获取字典列表失败')
}
} catch (error: any) {
ElMessage.error(error.message || '获取字典类型失败')
dictTypes.value = []
totalCount.value = 0
} catch (e) {
ElMessage.error(errMsg(e, '获取字典列表失败'))
} finally {
typeLoading.value = false
}
}
// 字典类型搜索
function handleTypeSearch() {
currentPage.value = 1
fetchDictTypes()
typePage.page = 1
loadTypes()
}
// 分页切换
function handlePageChange(page: number) {
currentPage.value = page
fetchDictTypes()
}
// 添加字典类型
function handleAddType() {
function openTypeCreate() {
currentDictType.value = null
typeDialogVisible.value = true
}
// 编辑字典类型
function handleEditType(row: any) {
function openTypeEdit(row) {
currentDictType.value = { ...row }
typeDialogVisible.value = true
}
// 查看字典项
function handleViewItems(row: any) {
selectedDictTypeId.value = row.id
currentView.value = 'items'
}
// 删除字典类型
async function handleDeleteType(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除字典类型「${row.dict_name}」吗?删除后不可恢复。`,
'警告',
{ type: 'warning' }
)
typeLoading.value = true
try {
const res = await deleteDictType(row.id)
if (res.success === true) {
ElMessage.success('删除成功')
fetchDictTypes()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error: any) {
ElMessage.error(error.message || '删除失败')
} finally {
typeLoading.value = false
}
} catch {
// 取消删除
/** 字典保存后刷新列表;新增的字典直接进入它的字典项 */
async function handleTypeSaved(created) {
await loadTypes()
if (created?.id) {
const hit = types.value.find((t) => t.id === created.id) || created
openItems(hit)
}
}
// 字典类型操作成功回调
function handleTypeSuccess() {
fetchDictTypes()
async function handleTypeDelete(row) {
try {
await ElMessageBox.confirm(
`确定要删除字典「${row.dict_name}」吗?删除后不可恢复,其下的字典项也会一并失效。`,
'删除确认',
{ type: 'warning' }
)
} catch {
return
}
try {
const res = await deleteDictType(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
loadTypes()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (e) {
ElMessage.error(errMsg(e, '删除失败'))
}
}
// 从字典项页返回
function goBackToTypes() {
currentView.value = 'types'
/* ---------------- 字典项 ---------------- */
/** 进入某个字典的字典项列表 */
function openItems(row) {
currentType.value = { ...row }
itemKeyword.value = ''
itemPage.page = 1
view.value = 'items'
loadItems()
}
// 子页面刷新后,静默刷新类型列表
function refreshTypesSilently() {
fetchDictTypes()
function goBackToList() {
view.value = 'list'
if (currentType.value) {
loadTypes() // 回列表时同步一下(字典项数量可能变了)
}
}
// 刷新
async function refresh() {
await fetchDictTypes()
ElMessage.success('刷新成功')
async function loadItems() {
if (!currentType.value?.id) return
itemLoading.value = true
try {
const res = await getDictItems({
dict_id: currentType.value.id,
page: itemPage.page,
pageSize: itemPage.pageSize,
keyword: itemKeyword.value
})
if (res.code === 200) {
items.value = res.data?.list || []
itemPage.total = res.data?.total || 0
} else {
ElMessage.error(res.msg || '获取字典项失败')
}
} catch (e) {
ElMessage.error(errMsg(e, '获取字典项失败'))
} finally {
itemLoading.value = false
}
}
onMounted(() => {
fetchDictTypes()
})
function handleItemSearch() {
itemPage.page = 1
loadItems()
}
function openItemCreate() {
if (!currentType.value) return
currentDictItem.value = null
itemDialogVisible.value = true
}
function openItemEdit(row) {
currentDictItem.value = { ...row }
itemDialogVisible.value = true
}
async function handleItemToggle(row) {
const next = row.status === 1 ? 0 : 1
try {
const res = await toggleDictItemStatus(row.id, next)
if (res.code === 200) {
row.status = next
ElMessage.success(next === 1 ? '已启用' : '已停用')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error(errMsg(e, '操作失败'))
}
}
async function handleItemDelete(row) {
try {
await ElMessageBox.confirm(
`确定要删除字典项「${row.item_label}」吗?删除后不可恢复。`,
'删除确认',
{ type: 'warning' }
)
} catch {
return
}
try {
const res = await deleteDictItem(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
loadItems()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (e) {
ElMessage.error(errMsg(e, '删除失败'))
}
}
</script>
<style scoped lang="less">
.container-box {
<style lang="less" scoped>
.dict-page {
padding: 20px;
display: flex;
flex-direction: column;
}
.header-bar {
.page-header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
align-items: flex-start;
margin-bottom: 16px;
h2 {
margin: 0;
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.sub-title {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 0 0;
}
.header-actions {
flex-shrink: 0;
display: flex;
gap: 10px;
}
}
.tab-content {
padding: 20px 0;
.crumb-link {
cursor: pointer;
color: var(--el-color-primary);
font-weight: 400;
&:hover {
text-decoration: underline;
}
}
.toolbar {
.dict-panel {
flex: 1;
min-height: 0;
display: flex;
justify-content: space-between;
flex-direction: column;
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.panel-header {
flex-shrink: 0;
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
margin-bottom: 12px;
}
.search-box {
display: flex;
align-items: center;
}
.panel-search {
width: 300px;
}
.filter-box {
display: flex;
align-items: center;
/* 表格高度自适应:行数少时按内容收缩,行数多时封顶并在表格内部滚动(表头常驻) */
.dict-table {
:deep(.el-table__body-wrapper) {
max-height: calc(100vh - 440px);
min-height: 160px;
overflow-y: auto;
}
}
.pagination-bar {
.panel-pagination {
flex-shrink: 0;
margin-top: 12px;
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.color-display {
display: flex;
align-items: center;
gap: 8px;
.muted {
color: var(--el-text-color-placeholder);
}
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #e5e5e5;
display: inline-block;
.inline-tag {
margin-left: 6px;
}
/* 屏幕很矮 / 窄屏时放宽封顶高度,交给页面滚动 */
@media (max-height: 620px), (max-width: 900px) {
.dict-table {
:deep(.el-table__body-wrapper) {
max-height: 360px;
}
}
}
</style>
@@ -5,7 +5,7 @@
<h2>支付宝配置</h2>
<p class="sub-title">平台使用费收款渠道,支持电脑网站支付与手机网站支付(RSA2 签名)。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict-code="payment_channel_state" />
</div>
<el-divider></el-divider>
@@ -5,7 +5,7 @@
<h2>云闪付配置</h2>
<p class="sub-title">云闪付走银联通道收款,复用银联商户参数,本页只控制收银台展示与开关。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict-code="payment_channel_state" />
</div>
<el-divider></el-divider>
@@ -5,7 +5,7 @@
<h2>PayPal 配置</h2>
<p class="sub-title">面向港澳台及海外租户的收款渠道,当前业务不跨境,默认保持停用。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict-code="payment_channel_state" />
</div>
<el-divider></el-divider>
@@ -5,7 +5,7 @@
<h2>银联配置</h2>
<p class="sub-title">银联全渠道收单(PC 网关支付 / 手机控件支付),参数由收单机构下发。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict-code="payment_channel_state" />
</div>
<el-divider></el-divider>
@@ -5,7 +5,7 @@
<h2>微信支付配置</h2>
<p class="sub-title">平台使用费收款主渠道,支持 JSAPI(公众号内)、Native(PC 扫码)支付。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict-code="payment_channel_state" />
</div>
<el-divider></el-divider>
@@ -97,7 +97,7 @@
</el-table-column>
<el-table-column label="状态" width="130" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="commission" />
<StatusTag :status="row.status" dict-code="payment_commission_status" />
</template>
</el-table-column>
<el-table-column label="发放方式" width="130" align="center">
@@ -139,12 +139,13 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { computed, reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
import { getCommissionList, getCommissionStats, markCommissionPaid } from '@/api/payment'
import { useDictStore } from '@/stores/dict'
const router = useRouter()
const loading = ref(false)
@@ -165,20 +166,12 @@ const filters = reactive<{
const page = reactive({ current: 1, size: 10, total: 0 })
const statusOptions = [
{ label: '应付', value: 'payable' },
{ label: '已付', value: 'settled' },
{ label: '税务处理中', value: 'tax_processing' },
{ label: '已作废', value: 'canceled' }
]
/** 佣金状态与发放方式取自数据字典 */
const dictStore = useDictStore()
const statusOptions = computed(() => dictStore.getSelectOptions('payment_commission_status'))
const payMethodMap: Record<string, string> = {
balance: '余额抵扣',
bank: '银行转账',
manual: '人工发放',
offset: '下期抵扣'
}
const payMethodLabel = (method: string) => payMethodMap[method] || method || '-'
const payMethodLabel = (method: string) =>
dictStore.getDictLabel('payment_commission_pay_method', method, method || '-')
/** 统计卡片数据。 */
const stats = reactive({
@@ -107,9 +107,9 @@
<el-form-item label="计算方式" prop="calc_type">
<el-radio-group v-model="form.calc_type">
<el-radio value="percent">按比例</el-radio>
<el-radio value="fixed">固定金额</el-radio>
<el-radio value="tiered">阶梯</el-radio>
<el-radio v-for="item in calcTypeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
@@ -165,10 +165,12 @@
<el-form-item label="适用订单类型" prop="order_type">
<el-select v-model="form.order_type" placeholder="请选择" style="width: 260px">
<el-option label="全部订单" value="all" />
<el-option label="平台使用费" value="platform_usage" />
<el-option label="模块购买" value="module_shop" />
<el-option label="服务费" value="service_fee" />
<el-option
v-for="item in orderTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
@@ -191,12 +193,13 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { computed, reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Plus, Edit, Delete, Tickets } from '@element-plus/icons-vue'
import { getCommissionRules, saveCommissionRule, updateCommissionRule, deleteCommissionRule, toggleCommissionRule } from '@/api/payment'
import { useDictStore } from '@/stores/dict'
const router = useRouter()
const loading = ref(false)
@@ -228,19 +231,24 @@ interface RuleRow {
const tableData = ref<RuleRow[]>([])
/** 计算方式与订单类型取自数据字典 */
const dictStore = useDictStore()
const calcTypeLabel = (type: string) =>
({ percent: '按比例', fixed: '固定金额', tiered: '阶梯' } as Record<string, string>)[type] || type
dictStore.getDictLabel('payment_calc_type', type, type)
const calcTypeTag = (type: string) =>
({ percent: 'primary', fixed: 'success', tiered: 'warning' } as Record<string, any>)[type] || 'info'
dictStore.getDictTagType('payment_calc_type', type)
const calcTypeOptions = computed(() => dictStore.getSelectOptions('payment_calc_type'))
const orderTypeMap: Record<string, string> = {
all: '全部订单',
platform_usage: '平台使用费',
module_shop: '模块购买',
service_fee: '服务费'
}
const orderTypeLabel = (type: string) => orderTypeMap[type] || type || '-'
/** 「全部订单」是筛选项而非业务取值,单独保留 */
const ALL_ORDER_TYPE = 'all'
const orderTypeOptions = computed(() => [
{ label: '全部订单', value: ALL_ORDER_TYPE },
...dictStore.getSelectOptions('payment_order_type')
])
const orderTypeLabel = (type: string) =>
type === ALL_ORDER_TYPE
? '全部订单'
: dictStore.getDictLabel('payment_order_type', type, type || '-')
/** 规则值展示 */
function valueText(row: RuleRow) {
@@ -12,7 +12,7 @@
<div class="merchant-no">{{ idLabel }}:{{ merchantNo || (status === 'unconfigured' ? '未配置' : '已配置') }}</div>
</div>
</div>
<StatusTag :status="status" dict="channel" />
<StatusTag :status="status" dict-code="payment_channel_state" />
</div>
<div v-if="desc" class="card-desc">{{ desc }}</div>
@@ -6,14 +6,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useDictStore } from '@/stores/dict'
type TagType = 'primary' | 'success' | 'info' | 'warning' | 'danger'
type DictName = 'common' | 'channel' | 'order' | 'commission' | 'reconcile' | 'diff'
interface StatusItem {
text: string
type: TagType
}
const props = withDefaults(
defineProps<{
@@ -21,93 +16,48 @@ const props = withDefaults(
status?: string | number
/** 自定义展示文案,不传则取字典里的文案 */
text?: string
/** 字典分组,不同业务模块状态含义不同 */
dict?: DictName
/** 字典编码:库里真实的 dict_code,如 payment_order_status */
dictCode?: string
/** 兜底字典编码:主字典查不到时按顺序再查(可选) */
fallbackCodes?: string[]
size?: 'large' | 'default' | 'small'
effect?: 'dark' | 'light' | 'plain'
}>(),
{
status: '',
text: '',
dict: 'common',
dictCode: '',
fallbackCodes: () => [],
size: 'small',
effect: 'light'
}
)
/** 通用状态(渠道 / 规则 / 单据都常见的枚举) */
const COMMON: Record<string, StatusItem> = {
'1': { text: '启用', type: 'success' },
'0': { text: '停用', type: 'info' },
enabled: { text: '已启用', type: 'success' },
disabled: { text: '已停用', type: 'info' },
unconfigured: { text: '未配置', type: 'warning' },
yes: { text: '是', type: 'success' },
no: { text: '否', type: 'info' }
const dictStore = useDictStore()
/** 查找顺序:主字典 → 兜底字典(都来自数据库,前端不维护映射表) */
const codes = computed(() => [props.dictCode, ...props.fallbackCodes].filter(Boolean) as string[])
if (codes.value.length) {
// 全量预热已完成时这里不会发请求
dictStore.preloadDicts(codes.value)
}
/** 渠道状态 */
const CHANNEL: Record<string, StatusItem> = {
enabled: { text: '已启用', type: 'success' },
disabled: { text: '已停用', type: 'info' },
unconfigured: { text: '未配置', type: 'warning' }
}
const statusKey = computed(() =>
props.status === undefined || props.status === null ? '' : String(props.status)
)
/** 支付单状态 */
const ORDER: Record<string, StatusItem> = {
created: { text: '已创建', type: 'info' },
pending: { text: '待支付', type: 'warning' },
paying: { text: '支付中', type: 'warning' },
paid: { text: '支付成功', type: 'success' },
success: { text: '支付成功', type: 'success' },
failed: { text: '支付失败', type: 'danger' },
closed: { text: '已关闭', type: 'info' },
timeout: { text: '已超时', type: 'info' },
refunding: { text: '退款中', type: 'warning' },
refunded: { text: '已退款', type: 'info' },
partial_refunded: { text: '部分退款', type: 'warning' }
}
/** 佣金状态 */
const COMMISSION: Record<string, StatusItem> = {
payable: { text: '应付', type: 'warning' },
settled: { text: '已付', type: 'success' },
tax_processing: { text: '税务处理中', type: 'primary' },
canceled: { text: '已作废', type: 'info' }
}
/** 对账处理状态 */
const RECONCILE: Record<string, StatusItem> = {
unhandled: { text: '待处理', type: 'warning' },
handling: { text: '处理中', type: 'primary' },
handled: { text: '已处理', type: 'success' },
ignored: { text: '已忽略', type: 'info' }
}
/** 对账差异类型 */
const DIFF: Record<string, StatusItem> = {
long: { text: '长款', type: 'warning' },
short: { text: '短款', type: 'danger' },
status_mismatch: { text: '状态不一致', type: 'warning' }
}
const DICTS: Record<DictName, Record<string, StatusItem>> = {
common: COMMON,
channel: CHANNEL,
order: ORDER,
commission: COMMISSION,
reconcile: RECONCILE,
diff: DIFF
}
const current = computed<StatusItem>(() => {
const key = props.status === undefined || props.status === null ? '' : String(props.status)
return (
DICTS[props.dict]?.[key] ||
COMMON[key] || {
text: key || '-',
type: 'info'
const current = computed<{ text: string; type: TagType }>(() => {
const key = statusKey.value
if (key) {
for (const code of codes.value) {
const hit = dictStore.getDictItem(code, key)
if (hit) {
return { text: hit.item_label, type: (hit.tag_type || 'info') as TagType }
}
}
)
}
// 字典里没有的取值:原样展示,避免吞掉后端新增的状态
return { text: key || '-', type: 'info' }
})
</script>
@@ -18,7 +18,7 @@
<template #title>
<div class="panel-title">
<span class="name">{{ doc.name }}</span>
<StatusTag :status="doc.status" dict="channel" />
<StatusTag :status="doc.status" dict-code="payment_channel_state" />
</div>
</template>
@@ -72,7 +72,7 @@
<el-table-column prop="scope" label="覆盖场景" min-width="220" />
<el-table-column prop="status" label="当前状态" width="140" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="channel" />
<StatusTag :status="row.status" dict-code="payment_channel_state" />
</template>
</el-table-column>
<el-table-column prop="remark" label="说明" min-width="260" show-overflow-tooltip />
+1 -1
View File
@@ -88,7 +88,7 @@
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="order" />
<StatusTag :status="row.status" dict-code="payment_order_status" />
</template>
</el-table-column>
<el-table-column label="时间" width="180" align="center">
@@ -22,13 +22,13 @@
<template #header>
<div class="card-header">
<span class="card-title">支付单信息</span>
<StatusTag :status="detail.status" dict="order" />
<StatusTag :status="detail.status" dict-code="payment_order_status" />
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="支付单号">{{ detail.pay_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付状态">
<StatusTag :status="detail.status" dict="order" />
<StatusTag :status="detail.status" dict-code="payment_order_status" />
</el-descriptions-item>
<el-descriptions-item label="支付金额">
<span class="amount">¥{{ fenToYuan(detail.amount) }}</span>
@@ -56,7 +56,7 @@
<span class="amount">¥{{ fenToYuan(detail.order_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="业务订单状态">
<StatusTag :status="detail.order_status" dict="order" />
<StatusTag :status="detail.order_status" dict-code="payment_order_status" />
</el-descriptions-item>
<el-descriptions-item label="订单摘要" :span="2">
{{ detail.subject || '-' }}
@@ -108,7 +108,7 @@
placement="top"
>
<div class="record-row">
<StatusTag :status="item.to_status" dict="order" />
<StatusTag :status="item.to_status" dict-code="payment_order_status" />
<span class="record-remark">{{ item.remark }}</span>
</div>
<div class="record-operator">操作来源:{{ item.operator }}</div>
@@ -126,6 +126,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
import { getOrderDetail, queryPayment, refundPayment } from '@/api/payment'
import { useDictStore } from '@/stores/dict'
const route = useRoute()
const router = useRouter()
@@ -174,21 +175,12 @@ const detail = reactive<Record<string, any>>({
/** 仅支付成功且未退款时允许手动原路退回(兜底能力) */
const canRefund = computed(() => detail.status === 'paid')
const channelMap: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
cloudpay: '云闪付',
paypal: 'PayPal'
}
const channelLabel = (channel: string) => channelMap[channel] || channel || '-'
const orderTypeMap: Record<string, string> = {
platform_usage: '平台使用费',
module_shop: '模块购买',
service_fee: '服务费'
}
const orderTypeLabel = (type: string) => orderTypeMap[type] || type || '-'
/** 渠道与订单类型文案统一取自数据字典 */
const dictStore = useDictStore()
const channelLabel = (channel: string) =>
dictStore.getDictLabel('payment_channel', channel, channel || '-')
const orderTypeLabel = (type: string) =>
dictStore.getDictLabel('payment_order_type', type, type || '-')
/* ---------------- 数据加载 ---------------- */
@@ -94,7 +94,7 @@
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="order" />
<StatusTag :status="row.status" dict-code="payment_order_status" />
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
@@ -121,13 +121,14 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { computed, reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
import { getOrders } from '@/api/payment'
import { getTenantList } from '@/api/tenant'
import { useDictStore } from '@/stores/dict'
const router = useRouter()
const loading = ref(false)
@@ -150,27 +151,15 @@ const filters = reactive<{
const page = reactive({ current: 1, size: 10, total: 0 })
const channelOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '云闪付', value: 'cloudpay' },
{ label: 'PayPal', value: 'paypal' }
]
const statusOptions = [
{ label: '待支付', value: 'pending' },
{ label: '支付中', value: 'paying' },
{ label: '支付成功', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已关闭', value: 'closed' },
{ label: '已退款', value: 'refunded' }
]
/** 渠道与状态选项统一取自数据字典(见 /platform/dict/options) */
const dictStore = useDictStore()
const channelOptions = computed(() => dictStore.getSelectOptions('payment_channel'))
const statusOptions = computed(() => dictStore.getSelectOptions('payment_order_status'))
const tenantOptions = ref<{ id: string | number; name: string }[]>([])
const channelName = (channel: string) =>
channelOptions.find((i) => i.value === channel)?.label || channel
dictStore.getDictLabel('payment_channel', channel, channel)
async function searchTenant(keyword: string) {
tenantLoading.value = true
@@ -48,17 +48,22 @@
</el-form-item>
<el-form-item label="差异类型">
<el-select v-model="filters.diff_type" placeholder="全部类型" clearable style="width: 160px">
<el-option label="长款" value="long" />
<el-option label="短款" value="short" />
<el-option label="状态不一致" value="status_mismatch" />
<el-option
v-for="item in diffTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="处理状态">
<el-select v-model="filters.handle_status" placeholder="全部状态" clearable style="width: 160px">
<el-option label="待处理" value="unhandled" />
<el-option label="处理中" value="handling" />
<el-option label="已处理" value="handled" />
<el-option label="已忽略" value="ignored" />
<el-option
v-for="item in handleStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
@@ -101,13 +106,13 @@
</el-table-column>
<el-table-column label="差异类型" width="180">
<template #default="{ row }">
<StatusTag :status="row.diff_type" dict="diff" />
<StatusTag :status="row.diff_type" dict-code="payment_reconcile_diff_type" />
<span class="diff-amount">{{ diffAmountText(row) }}</span>
</template>
</el-table-column>
<el-table-column label="处理状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.handle_status" dict="reconcile" />
<StatusTag :status="row.handle_status" dict-code="payment_reconcile_status" />
</template>
</el-table-column>
<el-table-column label="处理备注" min-width="180" show-overflow-tooltip>
@@ -147,11 +152,12 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { computed, reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
import { getReconcileList, markReconcileHandled } from '@/api/payment'
import { useDictStore } from '@/stores/dict'
const loading = ref(false)
@@ -164,22 +170,14 @@ const filters = reactive({
const page = reactive({ current: 1, size: 10, total: 0 })
const channelOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '云闪付', value: 'cloudpay' },
{ label: 'PayPal', value: 'paypal' }
]
/** 渠道与差异类型选项统一取自数据字典 */
const dictStore = useDictStore()
const channelOptions = computed(() => dictStore.getSelectOptions('payment_channel'))
const diffTypeOptions = computed(() => dictStore.getSelectOptions('payment_reconcile_diff_type'))
const handleStatusOptions = computed(() => dictStore.getSelectOptions('payment_reconcile_status'))
const channelMap: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
cloudpay: '云闪付',
paypal: 'PayPal'
}
const channelLabel = (channel: string) => channelMap[channel] || channel || '-'
const channelLabel = (channel: string) =>
dictStore.getDictLabel('payment_channel', channel, channel || '-')
/* ---------------- 列表数据 ---------------- */