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:
lzh
2026-03-22 14:54:44 +08:00
parent f98b4fa797
commit 7f34094642
2 changed files with 310 additions and 52 deletions

View File

@@ -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>

View 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>