做字典管理

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
@@ -19,6 +19,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']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
+64 -54
View File
@@ -1,114 +1,124 @@
import request from '@/utils/request'
// 获取字典类型列表
/**
* 数据字典接口(租户端)
*
* 表:yz_backend_dict / yz_backend_dict_item
* 租户从 JWT 解析,前端不传 tenant_id。
*/
const PREFIX = '/backend/dict'
/* ================= 字典列表(字典类型) ================= */
/** 字典列表(分页)params: { page, pageSize, keyword, status } */
export function getDictTypes(params) {
return request({
url: '/backend/dict/types',
url: `${PREFIX}/types`,
method: 'get',
params
})
}
// 根据ID获取字典类型
export function getDictTypeById(id) {
/** 新增字典 */
export function createDictType(data) {
return request({
url: `/backend/dict/types/${id}`,
method: 'get'
})
}
// 添加字典类型
export function addDictType(data) {
return request({
url: '/backend/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: `/backend/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: `/backend/dict/types/${id}`,
url: `${PREFIX}/types/${id}`,
method: 'delete'
})
}
// 获取字典项列表
/* ================= 字典项 ================= */
/** 字典项列表(分页)params: { dict_id, page, pageSize, keyword, status } */
export function getDictItems(params) {
return request({
url: '/backend/dict/items',
url: `${PREFIX}/items`,
method: 'get',
params
})
}
// 根据ID获取字典项
export function getDictItemById(id) {
/** 新增字典项 */
export function createDictItem(data) {
return request({
url: `/backend/dict/items/${id}`,
method: 'get'
})
}
// 添加字典项
export function addDictItem(data) {
return request({
url: '/backend/dict/items',
url: `${PREFIX}/items`,
method: 'post',
data
})
}
// 更新字典项
/** 编辑字典项 */
export function updateDictItem(id, data) {
return request({
url: `/backend/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: `/backend/dict/items/${id}`,
url: `${PREFIX}/items/${id}`,
method: 'delete'
})
}
// 根据字典编码获取字典项(用于业务查询)
export function getDictItemsByCode(code, includeDisabled = false) {
/* ================= 业务读取 ================= */
/** 按字典编码取「字典类型与字典项都启用」的选项,供业务页面下拉使用 */
export function getDictOptions(code) {
return request({
url: `/backend/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: '/backend/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, onBeforeUnmount, computed } from 'vue';
import { More, Close, CircleClose, ArrowUp, Fold, Expand } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
const tabsStore = useTabsStore();
const dictStore = useDictStore();
const router = useRouter();
const route = useRoute();
const defaultDashboardPath = '/home';
@@ -76,6 +78,9 @@ function restoreTabFromRoute() {
// 组件挂载后,根据当前路由恢复 tab
onMounted(() => {
// 进入后台后把库里所有字典预热到缓存(失败不影响按需加载)
dictStore.loadAll();
// 等待路由完全加载后再恢复
nextTick(() => {
// 刷新时,localStorage 中已保存的 tabs 会在 store 初始化时恢复
+14 -56
View File
@@ -25,27 +25,17 @@
</el-form-item>
<el-form-item label="客户类型">
<el-select v-model="filters.customer_type" clearable placeholder="全部" style="width: 130px">
<el-option label="企业" value="1" />
<el-option label="政府机构" value="2" />
<el-option label="国企" value="3" />
<el-option label="教育机构" value="4" />
<el-option label="个人" value="5" />
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="客户等级">
<el-select v-model="filters.customer_level" clearable placeholder="全部" style="width: 130px">
<el-option label="核心客户" value="1" />
<el-option label="重要客户" value="2" />
<el-option label="普通客户" value="3" />
<el-option label="潜在客户" value="4" />
<el-option v-for="item in levelOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option label="正常" value="1" />
<el-option label="禁用" value="0" />
<el-option label="冻结" value="2" />
<el-option label="已注销" value="3" />
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="数据状态">
@@ -210,13 +200,23 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { computed, ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Cpu } from '@element-plus/icons-vue'
import { getCustomerList, deleteCustomer, toggleCustomerStatus, convertCustomerToSupplier } from '@/api/customer'
import CustomerEdit from './components/edit.vue'
import ContactBook from '../components/contactBook.vue'
import SmartAddCompany from '../components/smartAddCompany.vue'
import { useErpPartyDict, formatDate, formatDateTime } from '../dict'
import { useDictStore } from '@/stores/dict'
// 类型/等级/状态的文案与标签色统一取自数据字典
const { customerTypeText, customerTypeTag, customerLevelText, customerLevelTag, statusText, statusTag } = useErpPartyDict()
const dictStore = useDictStore()
const typeOptions = computed(() => dictStore.getSelectOptions('erp_customer_type'))
const levelOptions = computed(() => dictStore.getSelectOptions('common_object_level'))
const statusOptions = computed(() => dictStore.getSelectOptions('common_status'))
const loading = ref(false)
const tableData = ref([])
@@ -300,19 +300,6 @@ function openContactBook(row) {
contactBookVisible.value = true
}
function formatDate(val) {
if (!val) return '-'
const d = new Date(val)
if (isNaN(d.getTime())) return '-'
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
function formatDateTime(val) {
if (!val) return '-'
const d = new Date(val)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
function copyInvoiceInfo() {
const d = detailData.value
if (!d) return
@@ -413,35 +400,6 @@ async function handleConvertSupplier(row) {
}
}
function customerTypeText(type) {
const map = { '1': '企业', '2': '政府机构', '3': '国企', '4': '教育机构', '5':'个人' }
return map[type] || type
}
function customerTypeTag(type) {
const map = { '1': 'primary', '2': 'warning', '3': 'success', '4': 'info' }
return map[type] || 'info'
}
function customerLevelText(level) {
const map = { '1': '核心客户', '2': '重要客户', '3': '普通客户', '4': '潜在客户' }
return map[level] || level
}
function customerLevelTag(level) {
const map = { '1': 'danger', '2': 'warning', '3': 'info', '4': '' }
return map[level] || ''
}
function statusText(status) {
const map = { '0': '禁用', '1': '正常', '2': '冻结', '3': '已注销' }
return map[status] || status
}
function statusTag(status) {
const map = { '0': 'info', '1': 'success', '2': 'warning', '3': 'danger' }
return map[status] || 'info'
}
</script>
<style lang="less" scoped>
+40
View File
@@ -0,0 +1,40 @@
import { useDictStore } from '@/stores/dict'
/**
* ERP 往来单位(客户 / 供应商)的字典取值。
*
* 文案与标签色来自数据字典接口,租户可在「系统设置 → 数据字典」维护:
* erp_customer_type / erp_supplier_type / common_object_level / common_status
*
* 用法:
* const { customerTypeText, customerTypeTag, statusText } = useErpPartyDict()
*/
export function useErpPartyDict() {
const dictStore = useDictStore()
return {
customerTypeText: (v) => dictStore.getDictLabel('erp_customer_type', v, v),
customerTypeTag: (v) => dictStore.getDictTagType('erp_customer_type', v),
customerLevelText: (v) => dictStore.getDictLabel('common_object_level', v, v),
customerLevelTag: (v) => dictStore.getDictTagType('common_object_level', v),
supplierTypeText: (v) => dictStore.getDictLabel('erp_supplier_type', v, v),
supplierTypeTag: (v) => dictStore.getDictTagType('erp_supplier_type', v),
supplierLevelText: (v) => dictStore.getDictLabel('common_object_level', v, v),
supplierLevelTag: (v) => dictStore.getDictTagType('common_object_level', v),
statusText: (v) => dictStore.getDictLabel('common_status', v, v),
statusTag: (v) => dictStore.getDictTagType('common_status', v)
}
}
export function formatDate(val) {
if (!val) return '-'
const d = new Date(val)
if (isNaN(d.getTime())) return '-'
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
export function formatDateTime(val) {
if (!val) return '-'
const d = new Date(val)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
@@ -0,0 +1,129 @@
<template>
<el-drawer
:model-value="visible"
:title="data?.supplier_name || '供应商详情'"
direction="rtl"
size="560px"
@update:model-value="emit('update:visible', $event)"
>
<div v-if="data" class="detail-content">
<div class="detail-actions">
<el-button type="success" size="small" @click="emit('convert', data)">转为客户</el-button>
</div>
<el-descriptions title="基本信息" :column="2" border size="small" label-width="120px">
<el-descriptions-item label="供应商名称" :span="2">{{ data.supplier_name }}</el-descriptions-item>
<el-descriptions-item label="供应商类型">{{ supplierTypeText(data.supplier_type) }}</el-descriptions-item>
<el-descriptions-item label="供应商等级">{{ supplierLevelText(data.supplier_level) }}</el-descriptions-item>
<el-descriptions-item label="所属行业">{{ data.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(data.status)" size="small">{{ statusText(data.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册资本">{{ data.registered_capital ? data.registered_capital + ' 万元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="实缴资本">{{ data.paid_capital ? data.paid_capital + ' 万元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="成立日期">{{ formatDate(data.establish_date) }}</el-descriptions-item>
<el-descriptions-item label="行政区划">{{ data.administrative_division || '-' }}</el-descriptions-item>
<el-descriptions-item label="企业类型">{{ data.enterprise_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="纳税人资质">{{ data.taxpayer_qualification || '-' }}</el-descriptions-item>
<el-descriptions-item label="经营范围" :span="2">{{ data.business_scope || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ formatDateTime(data.update_time) }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="联系信息" :column="2" border size="small" label-width="120px" style="margin-top: 20px">
<el-descriptions-item label="联系人">{{ data.contact_person || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ data.contact_phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系邮箱" :span="2">{{ data.contact_email || '-' }}</el-descriptions-item>
<el-descriptions-item label="供应商地址" :span="2">{{ data.address || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="2" border size="small" label-width="120px" style="margin-top: 20px">
<template #title>
<div class="desc-title-row">
<span>开票信息</span>
<el-button link type="primary" size="small" @click="copyInvoiceInfo">一键复制</el-button>
</div>
</template>
<el-descriptions-item label="发票抬头" :span="2">{{ data.invoice_title || '-' }}</el-descriptions-item>
<el-descriptions-item label="纳税人识别号">{{ data.tax_number || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册电话">{{ data.registered_phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册地址" :span="2">{{ data.registered_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="开户行名称">{{ data.bank_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="开户行账号">{{ data.bank_account || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="备注信息" :column="1" border size="small" label-width="120px" style="margin-top: 20px">
<el-descriptions-item label="备注">{{ data.remark || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
</template>
<script setup>
import { ElMessage } from 'element-plus'
import { useSupplierDict, formatDate, formatDateTime } from '../dict'
// 类型/等级/状态的文案与标签色统一取自数据字典
const { supplierTypeText, supplierLevelText, statusText, statusTag } = useSupplierDict()
const props = defineProps({
visible: { type: Boolean, default: false },
data: { type: Object, default: null }
})
const emit = defineEmits(['update:visible', 'convert'])
/** 一键复制开票信息(isSecureContext 之外的 http 环境下回退到 execCommand) */
function copyInvoiceInfo() {
const d = props.data
if (!d) return
const lines = [
`发票抬头:${d.invoice_title || ''}`,
`纳税人识别号:${d.tax_number || ''}`,
`注册地址:${d.registered_address || ''}`,
`注册电话:${d.registered_phone || ''}`,
`开户行名称:${d.bank_name || ''}`,
`开户行账号:${d.bank_account || ''}`
]
const text = lines.join('\n')
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('开票信息已复制到剪贴板')
}).catch(() => {
fallbackCopy(text)
})
} else {
fallbackCopy(text)
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
ElMessage.success('开票信息已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
document.body.removeChild(ta)
}
</script>
<style lang="less" scoped>
.desc-title-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,5 @@
/**
* 供应商模块的字典取值:实现已上移到 ../dict.js(客户/供应商共用同一套往来单位字典),
* 这里保留原有导出名,避免各页面改 import 路径。
*/
export { useErpPartyDict as useSupplierDict, formatDate, formatDateTime } from '../dict'
+20 -172
View File
@@ -25,26 +25,17 @@
</el-form-item>
<el-form-item label="供应商类型">
<el-select v-model="filters.supplier_type" clearable placeholder="全部" style="width: 140px">
<el-option label="原材料供应商" value="1" />
<el-option label="设备供应商" value="2" />
<el-option label="服务供应商" value="3" />
<el-option label="其他" value="4" />
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="供应商等级">
<el-select v-model="filters.supplier_level" clearable placeholder="全部" style="width: 130px">
<el-option label="核心供应商" value="1" />
<el-option label="重要供应商" value="2" />
<el-option label="普通供应商" value="3" />
<el-option label="潜在供应商" value="4" />
<el-option v-for="item in levelOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option label="正常" value="1" />
<el-option label="禁用" value="0" />
<el-option label="冻结" value="2" />
<el-option label="已注销" value="3" />
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="数据状态">
@@ -134,65 +125,12 @@
@success="fetchList"
/>
<!-- 详情抽屉 -->
<el-drawer
v-model="detailVisible"
:title="detailData?.supplier_name || '供应商详情'"
direction="rtl"
size="560px"
>
<div v-if="detailData" class="detail-content">
<div class="detail-actions">
<el-button type="success" size="small" @click="handleConvertCustomer(detailData)">转为客户</el-button>
<span class="detail-actions-tip">同一家单位可能既是供应商又是客户,转化后原供应商记录保留</span>
</div>
<el-descriptions title="基本信息" :column="2" border size="small" label-width="120px">
<el-descriptions-item label="供应商名称" :span="2">{{ detailData.supplier_name }}</el-descriptions-item>
<el-descriptions-item label="供应商类型">{{ supplierTypeText(detailData.supplier_type) }}</el-descriptions-item>
<el-descriptions-item label="供应商等级">{{ supplierLevelText(detailData.supplier_level) }}</el-descriptions-item>
<el-descriptions-item label="所属行业">{{ detailData.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(detailData.status)" size="small">{{ statusText(detailData.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册资本">{{ detailData.registered_capital ? detailData.registered_capital + ' 万元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="实缴资本">{{ detailData.paid_capital ? detailData.paid_capital + ' 万元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="成立日期">{{ formatDate(detailData.establish_date) }}</el-descriptions-item>
<el-descriptions-item label="行政区划">{{ detailData.administrative_division || '-' }}</el-descriptions-item>
<el-descriptions-item label="企业类型">{{ detailData.enterprise_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="纳税人资质">{{ detailData.taxpayer_qualification || '-' }}</el-descriptions-item>
<el-descriptions-item label="经营范围" :span="2">{{ detailData.business_scope || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ formatDateTime(detailData.update_time) }}</el-descriptions-item>
</el-descriptions>
<SupplierDetail
v-model:visible="detailVisible"
:data="detailData"
@convert="handleConvertCustomer"
/>
<el-descriptions title="联系信息" :column="2" border size="small" label-width="120px" style="margin-top: 20px">
<el-descriptions-item label="联系人">{{ detailData.contact_person || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detailData.contact_phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系邮箱" :span="2">{{ detailData.contact_email || '-' }}</el-descriptions-item>
<el-descriptions-item label="供应商地址" :span="2">{{ detailData.address || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="2" border size="small" label-width="120px" style="margin-top: 20px">
<template #title>
<div class="desc-title-row">
<span>开票信息</span>
<el-button link type="primary" size="small" @click="copyInvoiceInfo">一键复制</el-button>
</div>
</template>
<el-descriptions-item label="发票抬头" :span="2">{{ detailData.invoice_title || '-' }}</el-descriptions-item>
<el-descriptions-item label="纳税人识别号">{{ detailData.tax_number || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册电话">{{ detailData.registered_phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册地址" :span="2">{{ detailData.registered_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="开户行名称">{{ detailData.bank_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="开户行账号">{{ detailData.bank_account || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="备注信息" :column="1" border size="small" label-width="120px" style="margin-top: 20px">
<el-descriptions-item label="备注">{{ detailData.remark || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
<!-- 通讯录 -->
<ContactBook
v-model:visible="contactBookVisible"
:company-type="'supplier'"
@@ -209,13 +147,24 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { computed, ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Cpu } from '@element-plus/icons-vue'
import { getSupplierList, deleteSupplier, toggleSupplierStatus, convertSupplierToCustomer } from '@/api/supplier'
import SupplierEdit from './components/edit.vue'
import SupplierDetail from './components/detail.vue'
import ContactBook from '../components/contactBook.vue'
import SmartAddCompany from '../components/smartAddCompany.vue'
import { useSupplierDict, formatDateTime } from './dict'
import { useDictStore } from '@/stores/dict'
// 类型/等级/状态的文案与标签色统一取自数据字典
const { supplierTypeText, supplierTypeTag, supplierLevelText, supplierLevelTag, statusText, statusTag } = useSupplierDict()
const dictStore = useDictStore()
const typeOptions = computed(() => dictStore.getSelectOptions('erp_supplier_type'))
const levelOptions = computed(() => dictStore.getSelectOptions('common_object_level'))
const statusOptions = computed(() => dictStore.getSelectOptions('common_status'))
const loading = ref(false)
const tableData = ref([])
@@ -299,58 +248,6 @@ function openContactBook(row) {
contactBookVisible.value = true
}
function formatDate(val) {
if (!val) return '-'
const d = new Date(val)
if (isNaN(d.getTime())) return '-'
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
function formatDateTime(val) {
if (!val) return '-'
const d = new Date(val)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
function copyInvoiceInfo() {
const d = detailData.value
if (!d) return
const lines = [
`发票抬头:${d.invoice_title || ''}`,
`纳税人识别号:${d.tax_number || ''}`,
`注册地址:${d.registered_address || ''}`,
`注册电话:${d.registered_phone || ''}`,
`开户行名称:${d.bank_name || ''}`,
`开户行账号:${d.bank_account || ''}`
]
const text = lines.join('\n')
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('开票信息已复制到剪贴板')
}).catch(() => {
fallbackCopy(text)
})
} else {
fallbackCopy(text)
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
ElMessage.success('开票信息已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
document.body.removeChild(ta)
}
async function handleToggleStatus(row) {
const newStatus = row.status === '1' ? '0' : '1'
const action = newStatus === '1' ? '启用' : '禁用'
@@ -411,36 +308,6 @@ async function handleConvertCustomer(row) {
ElMessage.error(e.message || '转化失败')
}
}
function supplierTypeText(type) {
const map = { '1': '原材料供应商', '2': '设备供应商', '3': '服务供应商', '4': '其他' }
return map[type] || type
}
function supplierTypeTag(type) {
const map = { '1': 'primary', '2': 'warning', '3': 'success', '4': 'info' }
return map[type] || 'info'
}
function supplierLevelText(level) {
const map = { '1': '核心供应商', '2': '重要供应商', '3': '普通供应商', '4': '潜在供应商' }
return map[level] || level
}
function supplierLevelTag(level) {
const map = { '1': 'danger', '2': 'warning', '3': 'info', '4': '' }
return map[level] || ''
}
function statusText(status) {
const map = { '0': '禁用', '1': '正常', '2': '冻结', '3': '已注销' }
return map[status] || status
}
function statusTag(status) {
const map = { '0': 'info', '1': 'success', '2': 'warning', '3': 'danger' }
return map[status] || 'info'
}
</script>
<style lang="less" scoped>
@@ -502,28 +369,9 @@ function statusTag(status) {
}
}
.desc-title-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.detail-actions-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -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,414 +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 }
)
// 日期显示统一为:YYYY-MM-DD HH:mm:ss
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return String(dateStr)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// 获取字典类型列表(用于下拉选择)
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,308 +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
})
// 日期显示统一为:YYYY-MM-DD HH:mm:ss
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return String(dateStr)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// 获取字典类型列表
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>
+425 -265
View File
@@ -1,357 +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'
const currentView = ref<'types' | 'items'>('types')
/** 当前视图:list 字典列表 / items 字典项列表 */
const view = ref('list')
/* ---------------- 字典列表 ---------------- */
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 })
// 日期显示统一为:YYYY-MM-DD HH:mm:ss(utils/datetime)
function formatDate(dateStr: string | null | undefined): string {
return dateStr ? formatDateTime(dateStr) : ''
/** 进入字典项页时选中的字典 */
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>