feat(@vben/web-antd): 区域管理新增二维码功能及 UI 增强
- 新增单个区域二维码查看弹窗(qrcode-modal.vue),支持下载 PNG - 新增批量导出二维码 ZIP 功能,自动构建区域全路径名称 - 列表 UI 增强:区域类型/功能类型/等级改用彩色 Tag,状态改用 Switch 开关 - 点击行任意非操作列可展开/收起树节点 - 递归构建全路径名称时增加循环引用保护 - 移除设备绑定入口,替换为二维码操作 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,24 +2,31 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { OpsAreaApi } from '#/api/ops/area';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import {
|
||||
confirm as confirmModal,
|
||||
Page,
|
||||
useVbenDrawer,
|
||||
useVbenModal,
|
||||
} from '@vben/common-ui';
|
||||
import { confirm as confirmModal, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlob } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { message, Switch, Tag } from 'ant-design-vue';
|
||||
import JSZip from 'jszip';
|
||||
import QRCodeLib from 'qrcode';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteArea, getAreaTree, updateArea } from '#/api/ops/area';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DeviceBindDrawer from './modules/device-bind-drawer.vue';
|
||||
import {
|
||||
AREA_LEVEL_OPTIONS,
|
||||
AREA_LEVEL_TAG_COLORS,
|
||||
AREA_TYPE_OPTIONS,
|
||||
AREA_TYPE_TAG_COLORS,
|
||||
FUNCTION_TYPE_OPTIONS,
|
||||
FUNCTION_TYPE_TAG_COLORS,
|
||||
useGridColumns,
|
||||
useGridFormSchema,
|
||||
} from './data';
|
||||
import Form from './modules/form.vue';
|
||||
import QrcodeModal from './modules/qrcode-modal.vue';
|
||||
|
||||
defineOptions({ name: 'OpsBusArea' });
|
||||
|
||||
@@ -28,20 +35,23 @@ const [FormModal, formModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [DeviceBindDrawerComp, deviceBindDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: DeviceBindDrawer,
|
||||
const [QrcodeModalComp, qrcodeModalApi] = useVbenModal({
|
||||
connectedComponent: QrcodeModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 树形展开/收缩 */
|
||||
const isExpanded = ref(true);
|
||||
function handleExpand() {
|
||||
async function handleExpand() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
gridApi.grid?.setAllTreeExpand(isExpanded.value);
|
||||
await gridApi.grid?.setAllTreeExpand(isExpanded.value);
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
/** 刷新表格 */
|
||||
async function handleRefresh() {
|
||||
await gridApi.query();
|
||||
await nextTick();
|
||||
await gridApi.grid?.setAllTreeExpand(isExpanded.value);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
@@ -56,6 +66,123 @@ function handleEdit(row: OpsAreaApi.BusArea) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于扁平列表(带 parentId)构建 id → 全路径名称 映射
|
||||
* getData() 返回扁平数据(transform: true),通过 parentId 向上回溯拼接全名
|
||||
*/
|
||||
function buildFullNameMap(list: OpsAreaApi.BusArea[]): Map<number, string> {
|
||||
const nodeMap = new Map<number, OpsAreaApi.BusArea>();
|
||||
for (const item of list) {
|
||||
if (item.id !== null && item.id !== undefined)
|
||||
nodeMap.set(item.id, item);
|
||||
}
|
||||
|
||||
const cache = new Map<number, string>();
|
||||
|
||||
const visiting = new Set<number>();
|
||||
|
||||
function resolve(id: number): string {
|
||||
if (cache.has(id)) return cache.get(id)!;
|
||||
if (visiting.has(id)) return ''; // 防止循环引用导致无限递归
|
||||
const node = nodeMap.get(id);
|
||||
if (!node) return '';
|
||||
visiting.add(id);
|
||||
const parentId = node.parentId;
|
||||
const parentName =
|
||||
parentId !== null &&
|
||||
parentId !== undefined &&
|
||||
parentId !== 0 &&
|
||||
nodeMap.has(parentId)
|
||||
? resolve(parentId)
|
||||
: '';
|
||||
visiting.delete(id);
|
||||
const fullName = parentName
|
||||
? `${parentName}/${node.areaName}`
|
||||
: node.areaName;
|
||||
cache.set(id, fullName);
|
||||
return fullName;
|
||||
}
|
||||
|
||||
for (const item of list) {
|
||||
if (item.id !== null && item.id !== undefined) resolve(item.id);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function handleQrcode(row: OpsAreaApi.BusArea) {
|
||||
const data = getGridData();
|
||||
const fullNameMap = buildFullNameMap(data);
|
||||
const fullName = fullNameMap.get(row.id!) || row.areaName;
|
||||
qrcodeModalApi
|
||||
.setData({
|
||||
id: row.id,
|
||||
areaName: row.areaName,
|
||||
areaCode: row.areaCode,
|
||||
fullName,
|
||||
})
|
||||
.open();
|
||||
}
|
||||
|
||||
/** 获取当前表格扁平数据 */
|
||||
function getGridData(): OpsAreaApi.BusArea[] {
|
||||
return (gridApi.grid?.getData() as OpsAreaApi.BusArea[] | undefined) || [];
|
||||
}
|
||||
|
||||
interface FlatAreaItem extends OpsAreaApi.BusArea {
|
||||
fullName: string;
|
||||
}
|
||||
|
||||
/** 批量导出二维码 ZIP */
|
||||
async function handleBatchExportQrcode() {
|
||||
const data = getGridData();
|
||||
if (data.length === 0) {
|
||||
message.warning('暂无数据可导出');
|
||||
return;
|
||||
}
|
||||
const fullNameMap = buildFullNameMap(data);
|
||||
const flatList: FlatAreaItem[] = data.map((item) => ({
|
||||
...item,
|
||||
fullName: fullNameMap.get(item.id!) || item.areaName,
|
||||
}));
|
||||
if (flatList.length === 0) {
|
||||
message.warning('暂无数据可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
const hideLoading = message.loading({
|
||||
content: `正在生成 ${flatList.length} 个二维码...`,
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (const item of flatList) {
|
||||
const qrContent = JSON.stringify({
|
||||
type: 'AREA',
|
||||
id: item.id,
|
||||
code: item.areaCode,
|
||||
name: item.fullName,
|
||||
});
|
||||
const dataUrl = await QRCodeLib.toDataURL(qrContent, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
});
|
||||
// 去掉 data:image/png;base64, 前缀
|
||||
const base64Data = dataUrl.split(',')[1];
|
||||
if (!base64Data) continue;
|
||||
const fileName = `${item.areaName}_${item.areaCode || 'nocode'}.png`;
|
||||
zip.file(fileName, base64Data, { base64: true });
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFileFromBlob({ fileName: '区域二维码.zip', source: blob });
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
message.error('导出失败,请重试');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: OpsAreaApi.BusArea) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.areaName]),
|
||||
@@ -64,7 +191,7 @@ async function handleDelete(row: OpsAreaApi.BusArea) {
|
||||
try {
|
||||
await deleteArea(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.areaName]));
|
||||
handleRefresh();
|
||||
await handleRefresh();
|
||||
} catch (error: any) {
|
||||
const msg = error?.message || error?.data?.msg || '删除失败';
|
||||
message.error(msg);
|
||||
@@ -73,14 +200,17 @@ async function handleDelete(row: OpsAreaApi.BusArea) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态开关切换(带确认弹窗) */
|
||||
async function handleToggleActive(row: OpsAreaApi.BusArea) {
|
||||
const next = !row.isActive;
|
||||
if (!next) {
|
||||
try {
|
||||
await confirmModal('确认停用该区域吗?停用后相关工单策略可能受影响。');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await confirmModal(
|
||||
next
|
||||
? `确认启用区域【${row.areaName}】吗?`
|
||||
: `确认停用区域【${row.areaName}】吗?停用后相关工单策略可能受影响。`,
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const hideLoading = message.loading({
|
||||
content: next ? '启用中...' : '停用中...',
|
||||
@@ -89,7 +219,7 @@ async function handleToggleActive(row: OpsAreaApi.BusArea) {
|
||||
try {
|
||||
await updateArea({ ...row, isActive: next });
|
||||
message.success(next ? '已启用' : '已停用');
|
||||
handleRefresh();
|
||||
await handleRefresh();
|
||||
} catch (error: any) {
|
||||
const msg = error?.message || error?.data?.msg || '操作失败';
|
||||
message.error(msg);
|
||||
@@ -98,15 +228,6 @@ async function handleToggleActive(row: OpsAreaApi.BusArea) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleBindDevice(row: OpsAreaApi.BusArea) {
|
||||
deviceBindDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
const hasChildren = (row: OpsAreaApi.BusArea) => {
|
||||
const c = (row as any).children;
|
||||
return Array.isArray(c) && c.length > 0;
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
@@ -117,15 +238,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
pagerConfig: { enabled: false },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_: any, formValues: Record<string, any>) => {
|
||||
query: async (_params: any, formValues: Record<string, any>) => {
|
||||
const params: OpsAreaApi.AreaTreeQuery = {};
|
||||
if (formValues?.name) params.name = formValues.name;
|
||||
if (formValues?.areaType) params.areaType = formValues.areaType;
|
||||
if (formValues?.isActive !== undefined && formValues?.isActive !== '')
|
||||
params.isActive = formValues.isActive;
|
||||
const data = await getAreaTree(params);
|
||||
const list = Array.isArray(data) ? data : ((data as any)?.list ?? []);
|
||||
return { list, total: list.length };
|
||||
return await getAreaTree(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -141,19 +260,26 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
parentField: 'parentId',
|
||||
rowField: 'id',
|
||||
transform: true,
|
||||
expandAll: true,
|
||||
reserve: true,
|
||||
},
|
||||
} as VxeTableGridOptions<OpsAreaApi.BusArea>,
|
||||
gridEvents: {
|
||||
cellClick: ({ row, column, triggerTreeNode }: any) => {
|
||||
if (triggerTreeNode) return;
|
||||
// 状态开关列和操作列不触发展开
|
||||
if (column.field === 'isActive' || !column.field) return;
|
||||
gridApi.grid?.toggleTreeExpand(row);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<DeviceBindDrawerComp @refresh="handleRefresh" />
|
||||
<QrcodeModalComp />
|
||||
|
||||
<Grid table-title="业务区域管理">
|
||||
<Grid>
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -169,9 +295,72 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
type: 'primary',
|
||||
onClick: handleExpand,
|
||||
},
|
||||
{
|
||||
label: '批量导出二维码',
|
||||
type: 'default',
|
||||
icon: 'lucide:qr-code',
|
||||
onClick: handleBatchExportQrcode,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 区域类型 -->
|
||||
<template #areaType="{ row }">
|
||||
<Tag
|
||||
v-if="row.areaType"
|
||||
:color="AREA_TYPE_TAG_COLORS[row.areaType]"
|
||||
class="area-tag"
|
||||
>
|
||||
{{
|
||||
AREA_TYPE_OPTIONS.find((o) => o.value === row.areaType)?.label ??
|
||||
row.areaType
|
||||
}}
|
||||
</Tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 功能类型 -->
|
||||
<template #functionType="{ row }">
|
||||
<Tag
|
||||
v-if="row.functionType"
|
||||
:color="FUNCTION_TYPE_TAG_COLORS[row.functionType]"
|
||||
class="area-tag"
|
||||
>
|
||||
{{
|
||||
FUNCTION_TYPE_OPTIONS.find((o) => o.value === row.functionType)
|
||||
?.label ?? row.functionType
|
||||
}}
|
||||
</Tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 区域等级 -->
|
||||
<template #areaLevel="{ row }">
|
||||
<Tag
|
||||
v-if="row.areaLevel"
|
||||
:color="AREA_LEVEL_TAG_COLORS[row.areaLevel]"
|
||||
class="area-tag"
|
||||
>
|
||||
{{
|
||||
AREA_LEVEL_OPTIONS.find((o) => o.value === row.areaLevel)?.label ??
|
||||
row.areaLevel
|
||||
}}
|
||||
</Tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态开关 -->
|
||||
<template #isActive="{ row }">
|
||||
<Switch
|
||||
:checked="row.isActive"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
size="small"
|
||||
@click="handleToggleActive(row)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -190,18 +379,10 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
onClick: () => handleEdit(row),
|
||||
},
|
||||
{
|
||||
label: row.isActive ? '停用' : '启用',
|
||||
label: '二维码',
|
||||
type: 'link',
|
||||
icon: 'lucide:power',
|
||||
// auth: ['ops:area:update'],
|
||||
onClick: () => handleToggleActive(row),
|
||||
},
|
||||
{
|
||||
label: '绑定设备',
|
||||
type: 'link',
|
||||
icon: 'lucide:link',
|
||||
// auth: ['ops:area:bind-device'],
|
||||
onClick: () => handleBindDevice(row),
|
||||
icon: 'lucide:qr-code',
|
||||
onClick: () => handleQrcode(row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
@@ -209,7 +390,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
// auth: ['ops:area:delete'],
|
||||
disabled: hasChildren(row),
|
||||
disabled: Array.isArray(row.children) && row.children.length > 0,
|
||||
popConfirm: {
|
||||
title: `确认删除区域【${row.areaName}】吗?删除后其下级区域将无法归属,请先处理子区域或关联设备。`,
|
||||
confirm: () => handleDelete(row),
|
||||
@@ -221,3 +402,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 暖色 Tag 圆角微调 */
|
||||
.area-tag {
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
69
apps/web-antd/src/views/ops/area/modules/qrcode-modal.vue
Normal file
69
apps/web-antd/src/views/ops/area/modules/qrcode-modal.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBase64 } from '@vben/utils';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import QRCodeLib from 'qrcode';
|
||||
|
||||
interface AreaQrcodeData {
|
||||
areaCode?: string;
|
||||
areaName: string;
|
||||
fullName: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const areaInfo = ref<AreaQrcodeData | null>(null);
|
||||
|
||||
const qrDataUrl = ref('');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
areaInfo.value = null;
|
||||
qrDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<AreaQrcodeData>();
|
||||
if (!data) return;
|
||||
areaInfo.value = data;
|
||||
const qrContent = JSON.stringify({
|
||||
type: 'AREA',
|
||||
id: data.id,
|
||||
code: data.areaCode,
|
||||
name: data.fullName,
|
||||
});
|
||||
qrDataUrl.value = await QRCodeLib.toDataURL(qrContent, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function handleDownload() {
|
||||
if (!qrDataUrl.value || !areaInfo.value) return;
|
||||
const fileName = `${areaInfo.value.areaName}_${areaInfo.value.areaCode || 'nocode'}.png`;
|
||||
downloadFileFromBase64({ fileName, source: qrDataUrl.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="区域二维码" :footer="false">
|
||||
<div v-if="areaInfo" class="flex flex-col items-center gap-4 py-4">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="区域二维码"
|
||||
class="h-[220px] w-[220px]"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<div class="text-base font-medium">{{ areaInfo.fullName }}</div>
|
||||
<div v-if="areaInfo.areaCode" class="mt-1 text-sm text-gray-500">
|
||||
编码:{{ areaInfo.areaCode }}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" @click="handleDownload">下载 PNG</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user