From 3268de7494d5281186bb692635540e38db251e13 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 12 十月 2026 11:53:01 +0800
Subject: [PATCH] feat(dashboard): 添加权限控制和能耗分析功能
---
src/views/dashboard/analytics/modules/quick-actions.vue | 11
dist.zip | 0
src/views/mes/pd/project/index.vue | 2
src/views/mes/process-design/parameter/index.vue | 10
src/views/dashboard/analytics/use-home-access.ts | 30 ++
scripts/check-logout.cjs | 99 +++++++
src/views/hrm/resignation/index.vue | 2
src/api/energy/index.ts | 57 ++++
src/views/dashboard/analytics/modules/production-trend.vue | 6
src/views/energy/analysis/index.vue | 178 ++++++++++++
src/views/wls/returnvendor/index.vue | 2
src/store/company-config.ts | 8
src/packages/stores/src/setup.ts | 7
src/views/mes/process-design/template/index.vue | 8
src/views/im/home/store/uiStore.ts | 23 +
src/views/basicData/mdm/unit/index.vue | 4
src/views/energy/shared.ts | 18 +
src/views/energy/record/index.vue | 166 +++++++++++
scripts/check-analytics-permissions.cjs | 62 ++++
src/views/dashboard/analytics/modules/biz-cards.vue | 23 +
src/views/wls/materialstock/index.vue | 2
PRODUCT.md | 19 +
src/views/dashboard/analytics/modules/alert-panel.vue | 5
src/views/dashboard/analytics/modules/kpi-cards.vue | 24 +
src/views/wls/miscreceipt/index.vue | 12
src/views/im/home/store/rtcStore.ts | 6
src/views/dashboard/analytics/index.vue | 11
27 files changed, 753 insertions(+), 42 deletions(-)
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..7a6582f
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,19 @@
+# Product
+
+<!-- impeccable:product-schema 1 -->
+
+## Platform
+
+web
+
+## Operating Context
+
+鐜版湁 MOM 绠$悊鍚庡彴銆傛娆¤兘鑰楀姛鑳介潰鍚戦潚娴风殑涓�瀹跺寳浜悓浠佸爞浼佷笟锛屼富瑕佷骇鍝佸寘鍚嚂绐濆強婊嬭ˉ鍝佸師娴嗭紙鐢ㄦ埛鎻愪緵锛夈��
+
+## Capabilities and Constraints
+
+鑳借�楃鐞嗘槸鐙珛妯″潡锛屾墜鍔ㄦ寜澶╁綍鍏ユ按銆佺數銆佽捀姹戒娇鐢ㄤ俊鎭紝鎻愪緵澧炲垹鏀规煡鍜屾暟鎹垎鏋愪袱涓瓙鑿滃崟銆傜敤鎴峰凡纭鎸夊ぉ褰曞叆銆傚尯鍩熴�佷骇鍝佺被鍒�佺敤閫斿潎涓烘湰妯″潡淇℃伅锛屼笉鍏宠仈鍏朵粬涓氬姟妯″潡銆傚垎鏋愭敮鎸佹寜鏃�/鏈堟眹鎬汇��
+
+## Open Decisions
+
+浼佷笟姝e紡鍚嶇О銆佸疄闄呭尯鍩熷悕绉颁笌鑳芥簮鍗曚环鏈彁渚涳紱鍖哄煙寤鸿浠呯敤浜庡綍鍏ユ彁绀猴紝浠锋牸鐢辩敤鎴峰~鍐欍��
diff --git a/dist.zip b/dist.zip
new file mode 100644
index 0000000..ccc80a1
--- /dev/null
+++ b/dist.zip
Binary files differ
diff --git a/scripts/check-analytics-permissions.cjs b/scripts/check-analytics-permissions.cjs
new file mode 100644
index 0000000..5295a60
--- /dev/null
+++ b/scripts/check-analytics-permissions.cjs
@@ -0,0 +1,62 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const ts = require('typescript');
+const assert = require('node:assert/strict');
+const { parse, compileTemplate } = require('vue/compiler-sfc');
+const root = path.join(__dirname, '../src/views/dashboard/analytics');
+function evaluate(source, mocks) {
+ const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
+ const module = { exports: {} };
+ new Function('require', 'module', 'exports', 'defineOptions', 'defineProps', 'defineEmits', js)(
+ (id) => mocks[id] || {}, module, module.exports, () => {}, () => ({ repairActiveCount: 0 }), () => () => {});
+ return module.exports;
+}
+const vue = { computed: (getter) => ({ get value() { return getter(); } }), ref: (value) => ({ value }) };
+async function scenario(codes, names, expected) {
+ const store = { accessRoutes: [{ name: 'Parent', children: names.map((name) => ({ name })) }] };
+ const home = evaluate(fs.readFileSync(path.join(root, 'use-home-access.ts'), 'utf8'), {
+ vue, '@vben/stores': { useAccessStore: () => store },
+ '@vben/access': { useAccess: () => ({ hasAccessByCodes: (requested) => requested.some((code) => codes.includes(code)) }) },
+ });
+ const calls = [];
+ const mocks = { '../use-home-access': home, './use-home-access': home,
+ 'vue-router': { useRouter: () => ({ push: () => {} }) }, './data': { defaultSummary: {} } };
+ for (const [id, method] of [
+ ['#/api/mes/home', 'getHomeSummary'], ['#/api/mes/home', 'getProductionTrend'],
+ ['#/api/mes/home', 'getWorkOrderStatusDistribution'], ['#/api/erp/purchase/order', 'getPurchaseOrderPage'],
+ ['#/api/erp/sale/order', 'getSaleOrderPage'], ['#/api/mes/wm/materialstock', 'getMaterialStockPage'],
+ ['#/api/bpm/task', 'getTaskTodoPage'],
+ ]) {
+ mocks[id] ||= {};
+ mocks[id][method] = async () => { calls.push(method); return method.startsWith('getHome') ? {} : method.endsWith('Page') ? { total: 3 } : []; };
+ }
+ mocks['@vben/plugins/echarts'] = { useEcharts: () => ({ renderEcharts: () => {} }) };
+ for (const file of ['index.vue', 'modules/biz-cards.vue', 'modules/production-trend.vue', 'modules/alert-panel.vue']) {
+ const mounted = [];
+ mocks.vue = { ...vue, onMounted: (fn) => mounted.push(fn) };
+ const { descriptor } = parse(fs.readFileSync(path.join(root, file), 'utf8'));
+ evaluate(descriptor.scriptSetup.content, mocks);
+ mounted.forEach((fn) => fn());
+ }
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.deepEqual(calls.sort(), expected.sort());
+ const access = home.useHomeAccess();
+ store.accessRoutes = [{ name: 'Forbidden', meta: { menuVisibleWithForbidden: true }, children: [{ name: 'Child' }] }];
+ assert.equal(access.canNavigate('Forbidden'), false);
+ assert.equal(access.canNavigate('Child'), false);
+}
+(async () => {
+ await scenario([], [], []);
+ await scenario(['erp:purchase-order:query'], ['ErpPurchaseOrder'], ['getPurchaseOrderPage']);
+ await scenario(['erp:purchase-order:query'], [], []);
+ await scenario(['mes:home:query'], [], ['getHomeSummary', 'getProductionTrend', 'getWorkOrderStatusDistribution']);
+ await scenario(['*:*:*'], ['ErpPurchaseOrder', 'ErpSaleOrder', 'MesWmMaterialStock', 'BpmTaskTodo'],
+ ['getHomeSummary', 'getProductionTrend', 'getWorkOrderStatusDistribution', 'getPurchaseOrderPage', 'getSaleOrderPage', 'getMaterialStockPage', 'getTaskTodoPage']);
+ for (const file of ['index.vue', ...fs.readdirSync(path.join(root, 'modules')).filter((f) => f.endsWith('.vue')).map((f) => `modules/${f}`)]) {
+ const { descriptor, errors } = parse(fs.readFileSync(path.join(root, file), 'utf8'));
+ assert.equal(errors.length, 0, file);
+ const result = compileTemplate({ source: descriptor.template.content, filename: file, id: file });
+ assert.equal(result.errors.length, 0, `${file}: ${result.errors}`);
+ }
+ console.log('PASS: 5 permission scenarios, forbidden routes, and all analytics Vue templates');
+})();
diff --git a/scripts/check-logout.cjs b/scripts/check-logout.cjs
new file mode 100644
index 0000000..6b42309
--- /dev/null
+++ b/scripts/check-logout.cjs
@@ -0,0 +1,99 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const ts = require('typescript');
+const vue = require('vue');
+const pinia = require('pinia');
+const root = path.join(__dirname, '..');
+function load(file, mocks = {}) {
+ const source = fs.readFileSync(path.join(root, file), 'utf8')
+ .replaceAll('import.meta.hot', 'undefined')
+ .replaceAll('import.meta.env', '({ DEV: true })');
+ const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText;
+ const module = { exports: {} };
+ new Function('require', 'module', 'exports', 'localStorage', js)((id) => {
+ if (id in mocks) return mocks[id];
+ if (id === 'vue') return vue;
+ if (id === 'pinia') return pinia;
+ throw new Error(`Unexpected import ${id} in ${file}`);
+ }, module, module.exports, {});
+ return module.exports;
+}
+const constants = load('src/views/im/utils/constants.ts');
+async function scenario(apiFails, resetFails, redirect) {
+ const app = vue.createApp({});
+ const setup = load('src/packages/stores/src/setup.ts', {
+ 'secure-ls': class {}, 'pinia-plugin-persistedstate': { createPersistedState: () => () => {} },
+ });
+ const instance = await setup.initStores(app, { namespace: 'logout-test' });
+ pinia.setActivePinia(instance);
+ const company = load('src/store/company-config.ts', {
+ '#/api/system/company-config': {}, '#/utils/company-config': {},
+ }).useCompanyConfigStore();
+ company.companyName = 'Test company';
+ company.logoUrl = '/test-logo.png';
+ company.loginTitle = 'Test';
+ company.loginDescription = 'Test description';
+ const ui = load('src/views/im/home/store/uiStore.ts', { '../../utils/constants': constants }).useImUiStore();
+ ui.userInfoCard.show = true;
+ ui.userInfoCard.user = { id: 1 };
+ ui.contextMenu.items = [{ key: 'private', name: 'private' }];
+ ui.contextMenu.onSelect = () => {};
+ ui.nextUnreadJumpNonce = 3;
+ const rtc = load('src/views/im/home/store/rtcStore.ts', {
+ '../../utils/constants': constants, '#/views/im/utils/auth': {}, './friendStore': {},
+ './groupStore': { useGroupStore: () => ({ markGroupActiveCallLoaded: () => {} }) },
+ }).useRtcStore();
+ rtc.stage = constants.ImRtcCallStage.RUNNING;
+ rtc.call = { id: 1 };
+ rtc.setGroupCall({ groupId: 1, room: 'test', joinedUserIds: [1], inviteeIds: [] });
+ const accessModule = load('src/packages/stores/src/modules/access.ts');
+ const userModule = load('src/packages/stores/src/modules/user.ts');
+ const access = accessModule.useAccessStore();
+ access.setAccessToken('old-access');
+ access.setRefreshToken('old-refresh');
+ access.setIsAccessChecked(true);
+ access.setLoginExpired(true);
+ userModule.useUserStore().setUserInfo({ userId: 1 });
+ if (resetFails) pinia.defineStore('broken-business-store', () => ({ count: vue.ref(1) }))();
+ const navigations = [];
+ const apiCalls = [];
+ const auth = load('src/store/auth.ts', {
+ 'vue-router': { useRouter: () => ({ currentRoute: vue.ref({ fullPath: '/analytics?tab=1' }), replace: async (to) => navigations.push(to) }) },
+ '@vben/constants': { LOGIN_PATH: '/auth/login' }, '@vben/preferences': {},
+ '@vben/stores': { ...setup, ...accessModule, ...userModule }, 'ant-design-vue': {}, '#/locales': {},
+ '#/api': { logoutApi: async (token) => { apiCalls.push(token); if (apiFails) throw new Error('offline'); } },
+ }).useAuthStore();
+ const errors = [];
+ const originalError = console.error;
+ console.error = (...args) => errors.push(args);
+ try { await auth.logout(redirect); } finally { console.error = originalError; }
+ assert.deepEqual(apiCalls, ['old-access']);
+ assert.equal(navigations.length, 1);
+ assert.deepEqual(navigations[0], { path: '/auth/login', query: redirect ? { redirect: encodeURIComponent('/analytics?tab=1') } : {} });
+ assert.equal(access.accessToken, null);
+ assert.equal(access.refreshToken, null);
+ assert.equal(access.isAccessChecked, false);
+ assert.equal(access.loginExpired, false);
+ assert.equal(userModule.useUserStore().userInfo, null);
+ assert.equal(company.companyName, '');
+ assert.equal(company.logoUrl, '');
+ assert.equal(company.loginTitle, '');
+ assert.equal(company.loginDescription, '');
+ assert.equal(ui.userInfoCard.show, false);
+ assert.equal(ui.userInfoCard.user, null);
+ assert.deepEqual(ui.contextMenu.items, []);
+ assert.equal(ui.contextMenu.onSelect, null);
+ assert.equal(ui.nextUnreadJumpNonce, 0);
+ assert.equal(rtc.stage, constants.ImRtcCallStage.IDLE);
+ assert.equal(rtc.call, null);
+ assert.equal(rtc.getGroupCall(1), undefined);
+ assert.equal(errors.length, resetFails ? 1 : 0);
+}
+(async () => {
+ await scenario(false, false, false);
+ await scenario(true, false, true);
+ await scenario(false, true, false);
+ await scenario(true, true, false);
+ console.log('PASS: logout redirects and clears session with API success/failure and store reset failures; company and IM stores reset');
+})();
diff --git a/src/api/energy/index.ts b/src/api/energy/index.ts
new file mode 100644
index 0000000..c6bdb3e
--- /dev/null
+++ b/src/api/energy/index.ts
@@ -0,0 +1,57 @@
+import type { PageResult } from '@vben/request';
+import { requestClient } from '#/api/request';
+
+export type EnergyType = 'ELECTRICITY' | 'STEAM' | 'WATER';
+export interface EnergyRecord {
+ id?: number;
+ recordDate: string;
+ energyType: EnergyType;
+ usageArea: string;
+ productCategory: string;
+ usagePurpose: string;
+ quantity: number;
+ unitPrice: number;
+ amount?: number;
+ recorder: string;
+ remark?: string;
+ createTime?: string;
+}
+export interface EnergyFilters {
+ startDate?: string;
+ endDate?: string;
+ energyType?: EnergyType;
+ usageArea?: string;
+ productCategory?: string;
+}
+export interface EnergySummary {
+ energyType: EnergyType;
+ quantity: number;
+ amount: number;
+ recordCount: number;
+}
+export interface EnergyBucket extends EnergySummary { label: string }
+export interface EnergyAnalysis {
+ totalAmount: number;
+ recordCount: number;
+ summary: EnergySummary[];
+ trend: EnergyBucket[];
+ areas: EnergyBucket[];
+}
+export function getEnergyRecordPage(params: EnergyFilters & { pageNo: number; pageSize: number }) {
+ return requestClient.get<PageResult<EnergyRecord>>('/energy/record/page', { params });
+}
+export function getEnergyRecord(id: number) {
+ return requestClient.get<EnergyRecord>('/energy/record/get', { params: { id } });
+}
+export function createEnergyRecord(data: EnergyRecord) {
+ return requestClient.post<number>('/energy/record/create', data);
+}
+export function updateEnergyRecord(data: EnergyRecord) {
+ return requestClient.put('/energy/record/update', data);
+}
+export function deleteEnergyRecord(id: number) {
+ return requestClient.delete('/energy/record/delete', { params: { id } });
+}
+export function getEnergyAnalysis(params: EnergyFilters & { startDate: string; endDate: string; groupBy: 'DAY' | 'MONTH' }) {
+ return requestClient.get<EnergyAnalysis>('/energy/record/analysis', { params });
+}
diff --git a/src/packages/stores/src/setup.ts b/src/packages/stores/src/setup.ts
index 36b3a70..dbe882e 100644
--- a/src/packages/stores/src/setup.ts
+++ b/src/packages/stores/src/setup.ts
@@ -76,6 +76,11 @@
}
const allStores = (pinia as any)._s;
for (const [_key, store] of allStores) {
- store.$reset();
+ try {
+ store.$reset();
+ } catch (error) {
+ // 鍗曚釜涓氬姟 Store 鐨勫紓甯镐笉鑳戒腑鏂璇佹竻鐞嗗拰閫�鍑虹櫥褰曡烦杞��
+ console.error(`Failed to reset store: ${_key}`, error);
+ }
}
}
diff --git a/src/store/company-config.ts b/src/store/company-config.ts
index 1668f86..d3afef0 100644
--- a/src/store/company-config.ts
+++ b/src/store/company-config.ts
@@ -59,7 +59,15 @@
applyToPreferences();
}
+ function $reset() {
+ companyName.value = '';
+ logoUrl.value = '';
+ loginTitle.value = '';
+ loginDescription.value = '';
+ }
+
return {
+ $reset,
companyName,
logoUrl,
loginTitle,
diff --git a/src/views/basicData/mdm/unit/index.vue b/src/views/basicData/mdm/unit/index.vue
index 4a9fca8..428aaaf 100644
--- a/src/views/basicData/mdm/unit/index.vue
+++ b/src/views/basicData/mdm/unit/index.vue
@@ -122,14 +122,14 @@
label: $t('ui.actionTitle.import', ['璁¢噺鍗曚綅']),
type: 'primary',
icon: ACTION_ICON.UPLOAD,
- auth: ['mdm:unit-measure:import'],
+ auth: ['mdm:unit:import'],
onClick: handleImport,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
- auth: ['mdm:unit-measure:export'],
+ auth: ['mdm:unit:export'],
onClick: handleExport,
},
]"
diff --git a/src/views/dashboard/analytics/index.vue b/src/views/dashboard/analytics/index.vue
index 13794de..907fac8 100644
--- a/src/views/dashboard/analytics/index.vue
+++ b/src/views/dashboard/analytics/index.vue
@@ -4,7 +4,7 @@
import { Page } from '@vben/common-ui';
-import { Card, Col, Row } from 'ant-design-vue';
+import { Col, Row } from 'ant-design-vue';
import { getHomeSummary } from '#/api/mes/home';
@@ -15,11 +15,13 @@
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
import QuickActions from './modules/quick-actions.vue';
+import { useHomeAccess } from './use-home-access';
defineOptions({ name: 'DashboardAnalytics' });
const router = useRouter();
const summary = ref(defaultSummary);
+const { canNavigate, canViewHome } = useHomeAccess();
const greeting = computed(() => {
const hour = new Date().getHours();
@@ -38,10 +40,12 @@
});
function handleNavigate(name: string) {
+ if (!canNavigate(name)) return;
router.push({ name });
}
async function loadSummary() {
+ if (!canViewHome.value) return;
summary.value = await getHomeSummary();
}
@@ -72,19 +76,20 @@
</div>
<!-- MES 鐢熶骇 KPI -->
- <KpiCards :summary="summary" @navigate="handleNavigate" />
+ <KpiCards v-if="canViewHome" :summary="summary" @navigate="handleNavigate" />
<!-- ERP/WMS/BPM 缁忚惀 KPI -->
<BizCards @navigate="handleNavigate" />
<!-- AI 鏅鸿兘鍒嗘瀽 -->
<AiInsights
+ v-if="canViewHome && canNavigate('MesProWorkOrder')"
:active-order-count="summary.workOrderActiveCount"
@navigate="handleNavigate"
/>
<!-- 鐢熶骇瓒嬪娍 + 棰勮闈㈡澘 -->
- <Row :gutter="16" class="mb-4">
+ <Row v-if="canViewHome" :gutter="16" class="mb-4">
<Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4">
<div class="glass-card rounded-2xl p-5">
<h3 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-100">
diff --git a/src/views/dashboard/analytics/modules/alert-panel.vue b/src/views/dashboard/analytics/modules/alert-panel.vue
index 8ac16f2..4911ffa 100644
--- a/src/views/dashboard/analytics/modules/alert-panel.vue
+++ b/src/views/dashboard/analytics/modules/alert-panel.vue
@@ -12,6 +12,8 @@
import { getWorkOrderStatusDistribution } from '#/api/mes/home';
+import { useHomeAccess } from '../use-home-access';
+
defineOptions({ name: 'DashboardAlertPanel' });
const props = defineProps<{
@@ -25,8 +27,10 @@
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(false);
+const { canNavigate, canViewHome } = useHomeAccess();
async function loadStatus() {
+ if (!canViewHome.value) return;
loading.value = true;
try {
const data = await getWorkOrderStatusDistribution();
@@ -68,6 +72,7 @@
<!-- 棰勮鍗$墖琛� -->
<div class="grid grid-cols-1 gap-3">
<div
+ v-if="canNavigate('MesProRepair')"
class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
@click="emit('navigate', 'MesProRepair')"
>
diff --git a/src/views/dashboard/analytics/modules/biz-cards.vue b/src/views/dashboard/analytics/modules/biz-cards.vue
index 95a9e63..5fc8ff1 100644
--- a/src/views/dashboard/analytics/modules/biz-cards.vue
+++ b/src/views/dashboard/analytics/modules/biz-cards.vue
@@ -1,5 +1,5 @@
<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
+import { computed, onMounted, ref } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
@@ -11,6 +11,8 @@
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import { getTaskTodoPage } from '#/api/bpm/task';
+import { useHomeAccess } from '../use-home-access';
+
defineOptions({ name: 'DashboardBizCards' });
const emit = defineEmits<{
@@ -21,8 +23,14 @@
const saleCount = ref(0);
const stockCount = ref(0);
const bpmTodoCount = ref(0);
+const { canNavigate, canQuery } = useHomeAccess();
+const canViewPurchase = computed(() => canQuery('erp:purchase-order:query') && canNavigate('ErpPurchaseOrder'));
+const canViewSale = computed(() => canQuery('erp:sale-order:query') && canNavigate('ErpSaleOrder'));
+const canViewStock = computed(() => canQuery('mes:wm-material-stock:query') && canNavigate('MesWmMaterialStock'));
+const canViewBpmTodo = computed(() => canQuery('bpm:task:query') && canNavigate('BpmTaskTodo'));
async function loadPurchase() {
+ if (!canViewPurchase.value) return;
try {
const result = await getPurchaseOrderPage({ pageNo: 1, pageSize: 1 });
purchaseCount.value = result.total ?? 0;
@@ -30,6 +38,7 @@
}
async function loadSale() {
+ if (!canViewSale.value) return;
try {
const result = await getSaleOrderPage({ pageNo: 1, pageSize: 1 });
saleCount.value = result.total ?? 0;
@@ -37,6 +46,7 @@
}
async function loadStock() {
+ if (!canViewStock.value) return;
try {
const result = await getMaterialStockPage({ pageNo: 1, pageSize: 1 });
stockCount.value = result.total ?? 0;
@@ -44,6 +54,7 @@
}
async function loadBpmTodo() {
+ if (!canViewBpmTodo.value) return;
try {
const result = await getTaskTodoPage({ pageNo: 1, pageSize: 1 });
bpmTodoCount.value = result.total ?? 0;
@@ -83,9 +94,9 @@
</script>
<template>
- <Row :gutter="16">
+ <Row v-if="canViewPurchase || canViewSale || canViewStock || canViewBpmTodo" :gutter="16">
<!-- 閲囪喘璁㈠崟 -->
- <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
+ <Col v-if="canViewPurchase" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
:style="tiltStyle('purchase')"
@@ -112,7 +123,7 @@
</Col>
<!-- 閿�鍞鍗� -->
- <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
+ <Col v-if="canViewSale" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
:style="tiltStyle('sale')"
@@ -139,7 +150,7 @@
</Col>
<!-- 鐗╂枡搴撳瓨 -->
- <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
+ <Col v-if="canViewStock" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
:style="tiltStyle('stock')"
@@ -166,7 +177,7 @@
</Col>
<!-- BPM 寰呭姙 -->
- <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
+ <Col v-if="canViewBpmTodo" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
:style="tiltStyle('bpm')"
diff --git a/src/views/dashboard/analytics/modules/kpi-cards.vue b/src/views/dashboard/analytics/modules/kpi-cards.vue
index 761db56..88853ef 100644
--- a/src/views/dashboard/analytics/modules/kpi-cards.vue
+++ b/src/views/dashboard/analytics/modules/kpi-cards.vue
@@ -8,7 +8,11 @@
import { Col, Row } from 'ant-design-vue';
+import { useHomeAccess } from '../use-home-access';
+
defineOptions({ name: 'DashboardKpiCards' });
+
+const { canNavigate } = useHomeAccess();
const props = defineProps<{
summary: MesHomeApi.Summary;
@@ -61,11 +65,12 @@
<!-- 鐢熶骇璁㈠崟 -->
<Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
- class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
+ class="glass-tilt group overflow-hidden rounded-2xl border-0"
+ :class="{ 'cursor-pointer': canNavigate('MesProWorkOrder') }"
:style="tiltStyle('order')"
@mousemove="(e: MouseEvent) => onMouseMove(e, 'order')"
@mouseleave="onMouseLeave('order')"
- @click="emit('navigate', 'MesProWorkOrder')"
+ @click="canNavigate('MesProWorkOrder') && emit('navigate', 'MesProWorkOrder')"
>
<div class="relative z-10 flex items-center gap-4 p-5">
<div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 shadow-lg shadow-blue-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -92,11 +97,12 @@
<!-- 浠婃棩浜ч噺 -->
<Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
- class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
+ class="glass-tilt group overflow-hidden rounded-2xl border-0"
+ :class="{ 'cursor-pointer': canNavigate('MesProFeedback') }"
:style="tiltStyle('output')"
@mousemove="(e: MouseEvent) => onMouseMove(e, 'output')"
@mouseleave="onMouseLeave('output')"
- @click="emit('navigate', 'MesProFeedback')"
+ @click="canNavigate('MesProFeedback') && emit('navigate', 'MesProFeedback')"
>
<div class="relative z-10 flex items-center gap-4 p-5">
<div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-green-400 shadow-lg shadow-emerald-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -123,11 +129,12 @@
<!-- 璐ㄩ噺鍚堟牸鐜� -->
<Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
- class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
+ class="glass-tilt group overflow-hidden rounded-2xl border-0"
+ :class="{ 'cursor-pointer': canNavigate('MesQcRecord') }"
:style="tiltStyle('quality')"
@mousemove="(e: MouseEvent) => onMouseMove(e, 'quality')"
@mouseleave="onMouseLeave('quality')"
- @click="emit('navigate', 'MesQcRecord')"
+ @click="canNavigate('MesQcRecord') && emit('navigate', 'MesQcRecord')"
>
<div class="relative z-10 flex items-center gap-4 p-5">
<div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -156,11 +163,12 @@
<!-- 璁惧杩愯鐜� -->
<Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
<div
- class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
+ class="glass-tilt group overflow-hidden rounded-2xl border-0"
+ :class="{ 'cursor-pointer': canNavigate('MesDvMachinery') }"
:style="tiltStyle('equip')"
@mousemove="(e: MouseEvent) => onMouseMove(e, 'equip')"
@mouseleave="onMouseLeave('equip')"
- @click="emit('navigate', 'MesDvMachinery')"
+ @click="canNavigate('MesDvMachinery') && emit('navigate', 'MesDvMachinery')"
>
<div class="relative z-10 flex items-center gap-4 p-5">
<div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-400 shadow-lg shadow-violet-500/25 transition-transform duration-300 group-hover:scale-110">
diff --git a/src/views/dashboard/analytics/modules/production-trend.vue b/src/views/dashboard/analytics/modules/production-trend.vue
index e6ad064..45bc2bb 100644
--- a/src/views/dashboard/analytics/modules/production-trend.vue
+++ b/src/views/dashboard/analytics/modules/production-trend.vue
@@ -2,18 +2,22 @@
import type { MesHomeApi } from '#/api/mes/home';
import type { EchartsUIType } from '@vben/plugins/echarts';
-import { onMounted, ref, watch } from 'vue';
+import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { getProductionTrend } from '#/api/mes/home';
+import { useHomeAccess } from '../use-home-access';
+
defineOptions({ name: 'DashboardProductionTrend' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
+const { canViewHome } = useHomeAccess();
async function loadTrend() {
+ if (!canViewHome.value) return;
const data = await getProductionTrend(7);
const dates = data.map((d: MesHomeApi.ProductionTrend) => d.date.slice(5));
const quantities = data.map((d: MesHomeApi.ProductionTrend) => d.quantity);
diff --git a/src/views/dashboard/analytics/modules/quick-actions.vue b/src/views/dashboard/analytics/modules/quick-actions.vue
index 9a825b5..d7cc976 100644
--- a/src/views/dashboard/analytics/modules/quick-actions.vue
+++ b/src/views/dashboard/analytics/modules/quick-actions.vue
@@ -1,5 +1,9 @@
<script lang="ts" setup>
+import { computed } from 'vue';
+
import { IconifyIcon } from '@vben/icons';
+
+import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardQuickActions' });
@@ -25,14 +29,17 @@
{ title: '宸ヨ壓璺嚎', icon: 'lucide:route', route: 'MesPdRouting', gradient: 'from-teal-500 to-emerald-500', shadow: 'shadow-teal-500/15' },
{ title: '鏁版嵁鎶ヨ〃', icon: 'lucide:bar-chart-big', route: 'MesHomeIndex', gradient: 'from-pink-500 to-rose-500', shadow: 'shadow-pink-500/15' },
];
+
+const { canNavigate } = useHomeAccess();
+const accessibleActions = computed(() => actions.filter((action) => canNavigate(action.route)));
</script>
<template>
- <div class="glass-card rounded-2xl p-5">
+ <div v-if="accessibleActions.length" class="glass-card rounded-2xl p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-800 dark:text-gray-100">蹇嵎鎿嶄綔</h3>
<div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
<div
- v-for="action in actions"
+ v-for="action in accessibleActions"
:key="action.route"
class="group flex cursor-pointer flex-col items-center gap-2 rounded-xl p-3 transition-all duration-300 hover:-translate-y-1"
@click="emit('navigate', action.route)"
diff --git a/src/views/dashboard/analytics/use-home-access.ts b/src/views/dashboard/analytics/use-home-access.ts
new file mode 100644
index 0000000..65df114
--- /dev/null
+++ b/src/views/dashboard/analytics/use-home-access.ts
@@ -0,0 +1,30 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { computed } from 'vue';
+
+import { useAccess } from '@vben/access';
+import { useAccessStore } from '@vben/stores';
+
+/** 棣栭〉甯搁┗锛屼笟鍔℃暟鎹拰鍏ュ彛浠嶉渶閬靛惊褰撳墠鐢ㄦ埛鐨勮彍鍗曟潈闄愩�� */
+export function useHomeAccess() {
+ const accessStore = useAccessStore();
+ const { hasAccessByCodes } = useAccess();
+
+ function canQuery(permission: string) {
+ return hasAccessByCodes([permission, '*:*:*']);
+ }
+
+ function canNavigate(name: string) {
+ function contains(routes: RouteRecordRaw[]): boolean {
+ return routes.some((route) => {
+ if (route.meta?.menuVisibleWithForbidden) return false;
+ return route.name === name || contains(route.children ?? []);
+ });
+ }
+ return contains(accessStore.accessRoutes);
+ }
+
+ const canViewHome = computed(() => canQuery('mes:home:query'));
+
+ return { canNavigate, canQuery, canViewHome };
+}
diff --git a/src/views/energy/analysis/index.vue b/src/views/energy/analysis/index.vue
new file mode 100644
index 0000000..5ddc53e
--- /dev/null
+++ b/src/views/energy/analysis/index.vue
@@ -0,0 +1,178 @@
+<script setup lang="ts">
+import type { EchartsUIType } from '@vben/plugins/echarts';
+import type { EnergyAnalysis, EnergyFilters, EnergyType } from '#/api/energy';
+import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { Page } from '@vben/common-ui';
+import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
+import { Alert, Button, Card, DatePicker, Empty, Form, FormItem, Input, RadioGroup, Select, Space, Spin, Table, message } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { getEnergyAnalysis } from '#/api/energy';
+import { energyInfo, energyOptions, numberText, productOptions } from '../shared';
+
+defineOptions({ name: 'EnergyAnalysis' });
+const defaults = () => ({ startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD'), groupBy: 'DAY' as 'DAY' | 'MONTH' });
+const filters = reactive<EnergyFilters & ReturnType<typeof defaults>>(defaults());
+const applied = ref({ ...filters });
+const data = ref<EnergyAnalysis>();
+const loading = ref(false);
+const failed = ref(false);
+const selectedEnergy = ref<EnergyType>('WATER');
+const trendRef = ref<EchartsUIType>();
+const shareRef = ref<EchartsUIType>();
+const areaRef = ref<EchartsUIType>();
+const trendChart = useEcharts(trendRef);
+const shareChart = useEcharts(shareRef);
+const areaChart = useEcharts(areaRef);
+const hasRecords = computed(() => (data.value?.recordCount ?? 0) > 0);
+const summaryColumns = [
+ { title: '鑳芥簮', dataIndex: 'energyType' },
+ { title: '鎬荤敤閲�', dataIndex: 'quantity', align: 'right' as const },
+ { title: '璐圭敤锛堝厓锛�', dataIndex: 'amount', align: 'right' as const },
+ { title: '璐圭敤鍗犳瘮', key: 'share', align: 'right' as const },
+ { title: '褰曞叆鏉℃暟', dataIndex: 'recordCount', align: 'right' as const },
+];
+const detailColumns = [
+ { title: '鏃ユ湡 / 鏈堜唤', dataIndex: 'label' },
+ { title: '鑳芥簮', dataIndex: 'energyType' },
+ { title: '鐢ㄩ噺', dataIndex: 'quantity', align: 'right' as const },
+ { title: '璐圭敤锛堝厓锛�', dataIndex: 'amount', align: 'right' as const },
+ { title: '褰曞叆鏉℃暟', dataIndex: 'recordCount', align: 'right' as const },
+];
+let requestId = 0;
+async function load() {
+ if (!filters.startDate || !filters.endDate || filters.startDate > filters.endDate) {
+ message.warning('璇烽�夋嫨鏈夋晥鐨勫紑濮嬪拰缁撴潫鏃ユ湡'); return;
+ }
+ if (dayjs(filters.endDate).isAfter(dayjs(filters.startDate).add(2, 'year'))) {
+ message.warning('姣忔鍒嗘瀽璇烽�夋嫨涓ゅ勾浠ュ唴鐨勬棩鏈熻寖鍥�'); return;
+ }
+ const id = ++requestId;
+ const params = { ...filters };
+ loading.value = true;
+ failed.value = false;
+ try {
+ const result = await getEnergyAnalysis(params);
+ if (id !== requestId) return;
+ data.value = result;
+ applied.value = params;
+ selectedEnergy.value = params.energyType ?? 'WATER';
+ await nextTick();
+ await renderCharts();
+ } catch { if (id === requestId) { data.value = undefined; failed.value = true; } }
+ finally { if (id === requestId) loading.value = false; }
+}
+function reset() {
+ Object.keys(filters).forEach(key => delete (filters as Record<string, unknown>)[key]);
+ Object.assign(filters, defaults());
+ void load();
+}
+async function renderTrend() {
+ if (!hasRecords.value || !data.value) return;
+ const info = energyInfo[selectedEnergy.value];
+ const month = applied.value.groupBy === 'MONTH';
+ const periods: string[] = [];
+ let cursor = dayjs(applied.value.startDate).startOf(month ? 'month' : 'day');
+ const end = dayjs(applied.value.endDate);
+ while (!cursor.isAfter(end)) {
+ periods.push(cursor.format(month ? 'YYYY-MM' : 'YYYY-MM-DD'));
+ cursor = cursor.add(1, month ? 'month' : 'day');
+ }
+ const values = new Map(data.value.trend.filter(row => row.energyType === selectedEnergy.value).map(row => [row.label, Number(row.quantity)]));
+ await trendChart.renderEcharts({
+ animation: false,
+ tooltip: { trigger: 'axis', confine: true },
+ grid: { left: 20, right: 24, top: 40, bottom: 70, containLabel: true },
+ xAxis: { type: 'category', data: periods },
+ yAxis: { type: 'value', name: `${info.name}鐢ㄩ噺锛�${info.unit}锛塦 },
+ dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 12 }],
+ series: [{ name: `${info.name}锛�${info.unit}锛塦, type: 'line', showSymbol: periods.length < 40, connectNulls: false, itemStyle: { color: info.color }, data: periods.map(period => values.get(period) ?? null) }],
+ });
+}
+async function renderCharts() {
+ if (!hasRecords.value || !data.value) return;
+ await renderTrend();
+ if (Number(data.value.totalAmount) > 0) await shareChart.renderEcharts({
+ animation: false,
+ tooltip: { trigger: 'item', confine: true, formatter: '{b}锛歿c} 鍏冿紙{d}%锛�' },
+ legend: { bottom: 0 },
+ series: [{ type: 'pie', radius: '65%', center: ['50%', '45%'], label: { formatter: '{b}\n{d}%' }, data: data.value.summary.filter(row => Number(row.amount) > 0).map(row => ({ name: energyInfo[row.energyType].name, value: Number(row.amount), itemStyle: { color: energyInfo[row.energyType].color } })) }],
+ });
+ const totals = new Map<string, number>();
+ for (const row of data.value.areas) totals.set(row.label, (totals.get(row.label) ?? 0) + Number(row.amount));
+ const areaLabels = [...totals].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([label]) => label).reverse();
+ await areaChart.renderEcharts({
+ animation: false,
+ tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
+ legend: { bottom: 0 },
+ grid: { left: 16, right: 24, top: 30, bottom: 45, containLabel: true },
+ xAxis: { type: 'value', name: '鍏�' },
+ yAxis: { type: 'category', data: areaLabels, axisLabel: { width: 110, overflow: 'truncate' } },
+ series: energyOptions.map(option => ({
+ name: option.label, type: 'bar' as const, stack: 'amount', itemStyle: { color: energyInfo[option.value as EnergyType].color },
+ data: areaLabels.map(label => data.value!.areas.filter(row => row.label === label && row.energyType === option.value).reduce((sum, row) => sum + Number(row.amount), 0)),
+ })),
+ });
+}
+watch(selectedEnergy, () => { void renderTrend(); });
+onMounted(() => load());
+</script>
+
+<template>
+ <Page title="鑳借�楁暟鎹垎鏋�" description="鍩轰簬鎵嬪姩褰曞叆鐨勫彴璐︼紝鏌ョ湅鐢ㄩ噺瓒嬪娍鍙婅垂鐢ㄥ垎甯冦��">
+ <Card class="mb-4">
+ <Form :model="filters" layout="inline" class="analysis-filters" @finish="load">
+ <FormItem label="寮�濮嬫棩鏈�"><DatePicker v-model:value="filters.startDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
+ <FormItem label="缁撴潫鏃ユ湡"><DatePicker v-model:value="filters.endDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
+ <FormItem label="姹囨��"><Select v-model:value="filters.groupBy" :options="[{ label: '鎸夋棩', value: 'DAY' }, { label: '鎸夋湀', value: 'MONTH' }]" style="width: 100px" /></FormItem>
+ <FormItem label="鑳芥簮"><Select v-model:value="filters.energyType" :options="energyOptions" allow-clear placeholder="鍏ㄩ儴鑳芥簮" style="width: 130px" /></FormItem>
+ <FormItem label="鍖哄煙"><Input v-model:value="filters.usageArea" placeholder="鍖哄煙鍏抽敭璇�" allow-clear style="width: 150px" /></FormItem>
+ <FormItem label="浜у搧"><Select v-model:value="filters.productCategory" :options="productOptions" allow-clear placeholder="鍏ㄩ儴绫诲埆" style="width: 150px" /></FormItem>
+ <FormItem><Space><Button type="primary" :loading="loading" @click="load">鍒嗘瀽</Button><Button @click="reset">閲嶇疆</Button></Space></FormItem>
+ </Form>
+ </Card>
+ <Alert v-if="failed" type="error" show-icon message="鑳借�楀垎鏋愬姞杞藉け璐ワ紝璇风偣鍑诲垎鏋愰噸璇曘��" class="mb-4" />
+ <Spin :spinning="loading">
+ <template v-if="data">
+ <Card class="mb-4" title="鑳芥簮姹囨��">
+ <template #extra>{{ applied.startDate }} 鑷� {{ applied.endDate }}</template>
+ <div class="mb-4 flex flex-wrap gap-x-6 gap-y-2">
+ <span>鎬昏垂鐢細<strong>{{ numberText(data.totalAmount, 2) }} 鍏�</strong></span><span>宸插綍鍏� {{ data.recordCount }} 鏉�</span>
+ </div>
+ <Table :columns="summaryColumns" :data-source="data.summary" row-key="energyType" :pagination="false" :scroll="{ x: 600 }" size="small">
+ <template #bodyCell="{ column, record }">
+ <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
+ <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
+ <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
+ <template v-else-if="column.key === 'share'">{{ Number(data.totalAmount) > 0 ? numberText(Number(record.amount) / Number(data.totalAmount) * 100, 2) + '%' : '鈥�' }}</template>
+ </template>
+ </Table>
+ <p class="mb-0 mt-3 text-sm text-muted-foreground">姘淬�佺數銆佽捀姹芥寜鍚勮嚜鍗曚綅缁熻銆傛湭褰曞叆鏃ユ湡鍦ㄨ秼鍔夸腑鐣欑┖锛涘崟浠蜂负 0 鐨勮褰曚粛璁″叆鐢ㄩ噺銆�</p>
+ </Card>
+ <Card v-if="!hasRecords"><Empty description="鎵�閫夎寖鍥存殏鏃犺兘鑰楄褰曪紝璇疯皟鏁寸瓫閫夋潯浠舵垨鍏堝綍鍏ヨ兘鑰椾俊鎭��" /></Card>
+ <template v-else>
+ <Card class="mb-4" title="鐢ㄩ噺瓒嬪娍">
+ <template #extra><RadioGroup v-model:value="selectedEnergy" :options="energyOptions" option-type="button" size="small" /></template>
+ <EchartsUI ref="trendRef" class="h-[340px] w-full" />
+ </Card>
+ <div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
+ <Card title="鑳芥簮璐圭敤鍗犳瘮"><EchartsUI v-if="Number(data.totalAmount) > 0" ref="shareRef" class="h-[330px] w-full" /><Empty v-else description="灏氭湭褰曞叆璐圭敤锛屽~鍐欏崟浠峰悗鏄剧ず璐圭敤鍗犳瘮銆�" /></Card>
+ <Card title="鍖哄煙璐圭敤鍒嗗竷锛堝墠 10锛�"><EchartsUI ref="areaRef" class="h-[330px] w-full" /></Card>
+ </div>
+ <Card title="瓒嬪娍鏄庣粏">
+ <Table :columns="detailColumns" :data-source="data.trend" :row-key="row => `${row.label}-${row.energyType}`" :scroll="{ x: 600 }" :pagination="{ pageSize: 10, showSizeChanger: true }" size="small">
+ <template #bodyCell="{ column, record }">
+ <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
+ <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
+ <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
+ </template>
+ </Table>
+ </Card>
+ </template>
+ </template>
+ </Spin>
+ </Page>
+</template>
+
+<style scoped>
+.analysis-filters { gap: 8px 0; }
+</style>
diff --git a/src/views/energy/record/index.vue b/src/views/energy/record/index.vue
new file mode 100644
index 0000000..52ff946
--- /dev/null
+++ b/src/views/energy/record/index.vue
@@ -0,0 +1,166 @@
+<script setup lang="ts">
+import type { FormInstance, TablePaginationConfig } from 'ant-design-vue';
+import type { EnergyFilters, EnergyRecord } from '#/api/energy';
+import { computed, onMounted, reactive, ref } from 'vue';
+import { Page } from '@vben/common-ui';
+import { Alert, AutoComplete, Button, Card, DatePicker, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { createEnergyRecord, deleteEnergyRecord, getEnergyRecord, getEnergyRecordPage, updateEnergyRecord } from '#/api/energy';
+import { areaOptions, energyInfo, energyOptions, numberText, productOptions, purposeOptions } from '../shared';
+
+defineOptions({ name: 'EnergyRecord' });
+const filters = reactive<EnergyFilters>({});
+const rows = ref<EnergyRecord[]>([]);
+const loading = ref(false);
+const loadError = ref(false);
+const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
+const open = ref(false);
+const saving = ref(false);
+const detail = ref(false);
+const formRef = ref<FormInstance>();
+const initial = (): EnergyRecord => ({ recordDate: dayjs().format('YYYY-MM-DD'), energyType: 'WATER', usageArea: '', productCategory: '鐕曠獫', usagePurpose: '', quantity: 1, unitPrice: 0, recorder: '', remark: '' });
+const model = reactive<EnergyRecord>(initial());
+const unit = computed(() => energyInfo[model.energyType].unit);
+const estimatedAmount = computed(() => numberText(Number(model.quantity || 0) * Number(model.unitPrice || 0), 2));
+const title = computed(() => detail.value ? '鑳借�楄褰曡鎯�' : model.id ? '缂栬緫鑳借�楄褰�' : '鏂板鑳借�楄褰�');
+const rules = {
+ recordDate: [{ required: true, message: '璇烽�夋嫨缁熻鏃ユ湡' }],
+ energyType: [{ required: true, message: '璇烽�夋嫨鑳芥簮绫诲瀷' }],
+ usageArea: [{ required: true, whitespace: true, message: '璇疯緭鍏ヤ娇鐢ㄥ尯鍩�' }],
+ productCategory: [{ required: true, message: '璇烽�夋嫨浜у搧绫诲埆' }],
+ usagePurpose: [{ required: true, whitespace: true, message: '璇疯緭鍏ョ敤閫�' }],
+ quantity: [{ required: true, type: 'number' as const, min: 0.001, message: '鐢ㄩ噺蹇呴』澶т簬闆�' }],
+ unitPrice: [{ required: true, type: 'number' as const, min: 0, message: '鍗曚环涓嶈兘涓鸿礋鏁�' }],
+ recorder: [{ required: true, whitespace: true, message: '璇疯緭鍏ュ綍鍏ヤ汉' }],
+};
+const columns = [
+ { title: '缁熻鏃ユ湡', dataIndex: 'recordDate', width: 120 },
+ { title: '鑳芥簮', dataIndex: 'energyType', width: 95 },
+ { title: '浣跨敤鍖哄煙', dataIndex: 'usageArea', width: 160 },
+ { title: '浜у搧绫诲埆', dataIndex: 'productCategory', width: 130 },
+ { title: '鐢ㄩ��', dataIndex: 'usagePurpose', width: 170 },
+ { title: '鐢ㄩ噺', dataIndex: 'quantity', width: 150, align: 'right' as const },
+ { title: '鍗曚环锛堝厓/鍗曚綅锛�', dataIndex: 'unitPrice', width: 150, align: 'right' as const },
+ { title: '璐圭敤锛堝厓锛�', dataIndex: 'amount', width: 145, align: 'right' as const },
+ { title: '褰曞叆浜�', dataIndex: 'recorder', width: 110 },
+ { title: '鎿嶄綔', key: 'action', width: 190, fixed: 'right' as const },
+];
+let requestId = 0;
+async function load(reset = false) {
+ if (filters.startDate && filters.endDate && filters.startDate > filters.endDate) {
+ message.warning('寮�濮嬫棩鏈熶笉鑳芥櫄浜庣粨鏉熸棩鏈�'); return;
+ }
+ if (reset) pagination.current = 1;
+ const currentId = ++requestId;
+ loading.value = true;
+ loadError.value = false;
+ try {
+ const data = await getEnergyRecordPage({ ...filters, pageNo: pagination.current, pageSize: pagination.pageSize });
+ if (currentId !== requestId) return;
+ rows.value = data.list;
+ pagination.total = data.total;
+ } catch {
+ if (currentId === requestId) { rows.value = []; loadError.value = true; }
+ } finally { if (currentId === requestId) loading.value = false; }
+}
+function resetFilters() {
+ Object.keys(filters).forEach(key => delete filters[key as keyof EnergyFilters]);
+ void load(true);
+}
+function tableChange(page: TablePaginationConfig) {
+ pagination.current = page.current ?? 1;
+ pagination.pageSize = page.pageSize ?? 10;
+ void load();
+}
+async function showForm(row?: EnergyRecord, readonly = false) {
+ const values = row ? await getEnergyRecord(row.id!) : initial();
+ detail.value = readonly;
+ // 娓呴櫎鍓嶄竴娆$紪杈戠殑缂栧彿鍙婅鎯呭瓧娈点��
+ Object.keys(model).forEach(key => delete (model as unknown as Record<string, unknown>)[key]);
+ Object.assign(model, values);
+ open.value = true;
+ formRef.value?.clearValidate();
+}
+async function save() {
+ await formRef.value?.validate();
+ saving.value = true;
+ try {
+ const data = { ...model, usageArea: model.usageArea.trim(), usagePurpose: model.usagePurpose.trim(), recorder: model.recorder.trim() };
+ await (data.id ? updateEnergyRecord(data) : createEnergyRecord(data));
+ message.success('鑳借�楄褰曞凡淇濆瓨');
+ open.value = false;
+ await load();
+ } finally { saving.value = false; }
+}
+const deletingId = ref<number>();
+async function remove(row: EnergyRecord) {
+ deletingId.value = row.id;
+ try {
+ await deleteEnergyRecord(row.id!);
+ message.success('鑳借�楄褰曞凡鍒犻櫎');
+ if (rows.value.length === 1 && pagination.current > 1) pagination.current--;
+ await load();
+ } finally { deletingId.value = undefined; }
+}
+onMounted(() => load());
+</script>
+
+<template>
+ <Page title="鑳借�楄褰�" description="鎸夊ぉ鎵嬪姩璁板綍姘淬�佺數銆佽捀姹戒娇鐢ㄦ儏鍐碉紝璐圭敤鑷姩璁$畻銆�">
+ <Card>
+ <Form :model="filters" layout="inline" class="energy-filters" @finish="load(true)">
+ <FormItem label="寮�濮嬫棩鏈�"><DatePicker v-model:value="filters.startDate" value-format="YYYY-MM-DD" placeholder="寮�濮嬫棩鏈�" /></FormItem>
+ <FormItem label="缁撴潫鏃ユ湡"><DatePicker v-model:value="filters.endDate" value-format="YYYY-MM-DD" placeholder="缁撴潫鏃ユ湡" /></FormItem>
+ <FormItem label="鑳芥簮"><Select v-model:value="filters.energyType" :options="energyOptions" allow-clear placeholder="鍏ㄩ儴鑳芥簮" style="width: 130px" /></FormItem>
+ <FormItem label="鍖哄煙"><Input v-model:value="filters.usageArea" allow-clear placeholder="杈撳叆鍖哄煙鍏抽敭璇�" style="width: 160px" /></FormItem>
+ <FormItem label="浜у搧"><Select v-model:value="filters.productCategory" :options="productOptions" allow-clear placeholder="鍏ㄩ儴绫诲埆" style="width: 150px" /></FormItem>
+ <FormItem><Space><Button type="primary" :loading="loading" @click="load(true)">鏌ヨ</Button><Button @click="resetFilters">閲嶇疆</Button></Space></FormItem>
+ </Form>
+ <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
+ <span class="text-base font-semibold">鑳借�楀彴璐�</span>
+ <Button v-access:code="['energy:record:create']" type="primary" @click="showForm()">鏂板鑳借�楄褰�</Button>
+ </div>
+ <Alert v-if="loadError" type="error" message="鑳借�楄褰曞姞杞藉け璐ワ紝璇风偣鍑绘煡璇㈤噸璇曘��" show-icon class="mb-4" />
+ <Table :columns="columns" :data-source="rows" row-key="id" :loading="loading" :pagination="pagination" :scroll="{ x: 1420 }" :locale="{ emptyText: '鏆傛棤鑳借�楄褰曪紝璇锋柊澧炴瘡鏃ョ敤鑳戒俊鎭�' }" @change="tableChange">
+ <template #bodyCell="{ column, record }">
+ <template v-if="column.dataIndex === 'energyType'"><Tag>{{ energyInfo[record.energyType as keyof typeof energyInfo].name }}</Tag></template>
+ <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as keyof typeof energyInfo].unit }}</template>
+ <template v-else-if="column.dataIndex === 'unitPrice'">{{ numberText(record.unitPrice, 4) }}</template>
+ <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
+ <template v-else-if="column.key === 'action'">
+ <Space :size="0">
+ <Button v-access:code="['energy:record:query']" type="link" size="small" @click="showForm(record as EnergyRecord, true)">璇︽儏</Button>
+ <Button v-access:code="['energy:record:update']" type="link" size="small" @click="showForm(record as EnergyRecord)">缂栬緫</Button>
+ <Popconfirm title="纭畾鍒犻櫎杩欐潯鑳借�楄褰曪紵鍒犻櫎鍚庣粺璁℃暟鎹篃浼氭洿鏂般��" ok-text="纭畾" cancel-text="鍙栨秷" @confirm="remove(record as EnergyRecord)">
+ <Button v-access:code="['energy:record:delete']" type="link" danger size="small" :loading="deletingId === record.id">鍒犻櫎</Button>
+ </Popconfirm>
+ </Space>
+ </template>
+ </template>
+ </Table>
+ </Card>
+ <Modal v-model:open="open" :title="title" :width="760" :confirm-loading="saving" :closable="!saving" :mask-closable="!saving" :keyboard="!saving" :cancel-button-props="{ disabled: saving }" :footer="detail ? null : undefined" ok-text="淇濆瓨" cancel-text="鍙栨秷" @ok="save">
+ <Form ref="formRef" :model="model" :rules="rules" :disabled="detail || saving" layout="vertical" class="energy-form">
+ <FormItem label="缁熻鏃ユ湡" name="recordDate"><DatePicker v-model:value="model.recordDate" value-format="YYYY-MM-DD" :disabled-date="date => date.isAfter(dayjs(), 'day')" class="w-full" /></FormItem>
+ <FormItem label="鑳芥簮绫诲瀷" name="energyType"><Select v-model:value="model.energyType" :options="energyOptions" /></FormItem>
+ <FormItem label="浣跨敤鍖哄煙" name="usageArea"><AutoComplete v-model:value="model.usageArea" :options="areaOptions" :maxlength="100" placeholder="閫夋嫨寤鸿鍖哄煙鎴栨墜鍔ㄨ緭鍏�" /></FormItem>
+ <FormItem label="浜у搧绫诲埆" name="productCategory"><Select v-model:value="model.productCategory" :options="productOptions" /></FormItem>
+ <FormItem label="鐢ㄩ�� / 宸ュ簭" name="usagePurpose"><AutoComplete v-model:value="model.usagePurpose" :options="purposeOptions" :maxlength="100" placeholder="濡傝澶囨竻娲椼�佽捀鐓彁鍙栥�佺伃鑿�" /></FormItem>
+ <FormItem label="褰曞叆浜�" name="recorder"><Input v-model:value="model.recorder" :maxlength="50" placeholder="璇疯緭鍏ュ鍚�" /></FormItem>
+ <FormItem :label="`鐢ㄩ噺锛�${unit}锛塦" name="quantity"><InputNumber v-model:value="model.quantity" :min="0.001" :max="999999999999999.999" :precision="3" style="width: 100%" /></FormItem>
+ <FormItem :label="`鍗曚环锛堝厓/${unit}锛塦" name="unitPrice"><InputNumber v-model:value="model.unitPrice" :min="0" :max="99999999.9999" :precision="4" style="width: 100%" /></FormItem>
+ <div class="col-span-full mb-4 rounded border border-solid border-[hsl(var(--border))] p-3">
+ 璐圭敤锛�<strong>{{ detail ? numberText(model.amount, 2) : estimatedAmount }} 鍏�</strong>
+ <div class="mt-1 text-sm text-muted-foreground">鎸夌敤閲� 脳 鍗曚环璁$畻锛屼繚瀛樻椂绮剧‘鍒板垎銆備粎璁板綍鐢ㄩ噺鏃讹紝鍗曚环鍙~ 0銆�</div>
+ </div>
+ <FormItem label="澶囨敞" name="remark" class="col-span-full"><Input.TextArea v-model:value="model.remark" :maxlength="500" show-count :rows="3" placeholder="濡傝閲忓彛寰勩�佺壒娈婄敤鑳芥儏鍐电瓑" /></FormItem>
+ </Form>
+ </Modal>
+ </Page>
+</template>
+
+<style scoped>
+.energy-filters { gap: 8px 0; margin-bottom: 24px; }
+.energy-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 20px; }
+@media (max-width: 640px) { .energy-form { grid-template-columns: minmax(0, 1fr); } }
+</style>
diff --git a/src/views/energy/shared.ts b/src/views/energy/shared.ts
new file mode 100644
index 0000000..d59185c
--- /dev/null
+++ b/src/views/energy/shared.ts
@@ -0,0 +1,18 @@
+import type { EnergyType } from '#/api/energy';
+
+export const energyOptions = [
+ { label: '姘�', value: 'WATER' },
+ { label: '鐢�', value: 'ELECTRICITY' },
+ { label: '钂告苯', value: 'STEAM' },
+];
+export const energyInfo: Record<EnergyType, { name: string; unit: string; color: string }> = {
+ WATER: { name: '姘�', unit: 'm鲁', color: '#1677ff' },
+ ELECTRICITY: { name: '鐢�', unit: 'kWh', color: '#d48806' },
+ STEAM: { name: '钂告苯', unit: 't', color: '#389e0d' },
+};
+export const productOptions = ['鐕曠獫', '婊嬭ˉ鍝佸師娴�', '鍏敤鑳借��', '鍏朵粬'].map(value => ({ label: value, value }));
+export const areaOptions = ['鐕曠獫鐢熶骇鍖�', '鍘熸祮鐢熶骇鍖�', '娓呮礂鍖�', '鐏弻鍖�', '鐏岃鍖呰鍖�', '鍔ㄥ姏绔�', '浠撳偍鍖�', '鍔炲叕鐢熸椿鍖�'].map(value => ({ value }));
+export const purposeOptions = ['鍘熸枡娓呮礂', '璁惧娓呮礂 / CIP', '钂哥叜鎻愬彇', '鐏弻', '鐏岃鍖呰', '鍒跺喎', '閲囨殩', '鏃ュ父鐢ㄨ兘'].map(value => ({ value }));
+export function numberText(value: number | undefined, digits = 3) {
+ return Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits === 2 ? 2 : 0 });
+}
diff --git a/src/views/hrm/resignation/index.vue b/src/views/hrm/resignation/index.vue
index cdf72da..0166dd8 100644
--- a/src/views/hrm/resignation/index.vue
+++ b/src/views/hrm/resignation/index.vue
@@ -163,7 +163,7 @@
{
label: '浜ゆ帴',
type: 'link',
- auth: ['hrm:resignation:handover'],
+ auth: ['hrm:resignation:update'],
ifShow: row.status === 20 && row.handoverStatus !== 20,
popConfirm: {
title: row.handoverStatus === 0 ? '纭寮�濮嬩氦鎺ュ悧锛�' : '纭瀹屾垚浜ゆ帴鍚楋紵',
diff --git a/src/views/im/home/store/rtcStore.ts b/src/views/im/home/store/rtcStore.ts
index 46a98b9..9934f84 100644
--- a/src/views/im/home/store/rtcStore.ts
+++ b/src/views/im/home/store/rtcStore.ts
@@ -422,7 +422,13 @@
})
}
+ function $reset() {
+ reset()
+ clearGroupCallCache()
+ }
+
return {
+ $reset,
stage,
call,
incomingPayload,
diff --git a/src/views/im/home/store/uiStore.ts b/src/views/im/home/store/uiStore.ts
index 93d0b6e..bf5cb82 100644
--- a/src/views/im/home/store/uiStore.ts
+++ b/src/views/im/home/store/uiStore.ts
@@ -129,7 +129,30 @@
nextUnreadJumpNonce.value++
}
+ function $reset() {
+ Object.assign(userInfoCard, {
+ show: false,
+ user: null,
+ position: { x: 0, y: 0 },
+ addSource: ImFriendAddSource.SEARCH,
+ addSourceExtra: ''
+ })
+ Object.assign(groupInfoCard, {
+ show: false,
+ group: null,
+ position: { x: 0, y: 0 }
+ })
+ Object.assign(contextMenu, {
+ show: false,
+ position: { x: 0, y: 0 },
+ items: [],
+ onSelect: null
+ })
+ nextUnreadJumpNonce.value = 0
+ }
+
return {
+ $reset,
userInfoCard,
openUserInfoCard,
openUserInfoCardAtEvent,
diff --git a/src/views/mes/pd/project/index.vue b/src/views/mes/pd/project/index.vue
index 05c74ce..a511ad9 100644
--- a/src/views/mes/pd/project/index.vue
+++ b/src/views/mes/pd/project/index.vue
@@ -259,7 +259,7 @@
{
label: '褰掓。',
type: 'link',
- auth: ['mes:pd-project:archive'],
+ auth: ['mes:pd-project:update'],
ifShow: row.status === MesPdProjectStatusEnum.PENDING_ARCHIVE,
popConfirm: {
title: '纭褰掓。锛熷綊妗e悗灏嗙敓鎴愬彴璐﹁褰曪紝浠诲姟涓嶅彲鍐嶄慨鏀广��',
diff --git a/src/views/mes/process-design/parameter/index.vue b/src/views/mes/process-design/parameter/index.vue
index f8ff530..83ec01c 100644
--- a/src/views/mes/process-design/parameter/index.vue
+++ b/src/views/mes/process-design/parameter/index.vue
@@ -120,14 +120,14 @@
label: $t('ui.actionTitle.create', ['鍙傛暟']),
type: 'primary',
icon: ACTION_ICON.ADD,
- auth: ['mes:pd:parameter:create'],
+ auth: ['mes:pd-process-param:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
- auth: ['mes:pd:parameter:export'],
+ auth: ['mes:pd-process-param:export'],
onClick: handleExport,
},
]"
@@ -143,14 +143,14 @@
label: '鍏宠仈宸ュ簭',
type: 'link',
icon: ACTION_ICON.LINK,
- auth: ['mes:pd:parameter:update'],
+ auth: ['mes:pd-process-param:update'],
onClick: handleViewProcessLink.bind(null, row),
},
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
- auth: ['mes:pd:parameter:update'],
+ auth: ['mes:pd-process-param:update'],
onClick: handleEdit.bind(null, row),
},
{
@@ -158,7 +158,7 @@
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
- auth: ['mes:pd:parameter:delete'],
+ auth: ['mes:pd-process-param:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.paramName]),
confirm: handleDelete.bind(null, row),
diff --git a/src/views/mes/process-design/template/index.vue b/src/views/mes/process-design/template/index.vue
index 10929c9..f611870 100644
--- a/src/views/mes/process-design/template/index.vue
+++ b/src/views/mes/process-design/template/index.vue
@@ -106,14 +106,14 @@
label: $t('ui.actionTitle.create', ['妯℃澘']),
type: 'primary',
icon: ACTION_ICON.ADD,
- auth: ['mes:pd:template:create'],
+ auth: ['mes:pd-process-param-template:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
- auth: ['mes:pd:template:export'],
+ auth: ['mes:pd-process-param-template:export'],
onClick: handleExport,
},
]" />
@@ -134,7 +134,7 @@
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
- auth: ['mes:pd:template:update'],
+ auth: ['mes:pd-process-param-template:update'],
onClick: handleEdit.bind(null, row),
},
{
@@ -142,7 +142,7 @@
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
- auth: ['mes:pd:template:delete'],
+ auth: ['mes:pd-process-param-template:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.templateName]),
confirm: handleDelete.bind(null, row),
diff --git a/src/views/wls/materialstock/index.vue b/src/views/wls/materialstock/index.vue
index 91a398b..ba1d713 100644
--- a/src/views/wls/materialstock/index.vue
+++ b/src/views/wls/materialstock/index.vue
@@ -241,7 +241,7 @@
{
label: '绉诲簱',
type: 'link',
- auth: ['mes:wm-material-stock:move'],
+ auth: ['mes:wm-material-stock:update'],
ifShow: !row.frozen,
onClick: handleOpenStockMove.bind(null, row),
},
diff --git a/src/views/wls/miscreceipt/index.vue b/src/views/wls/miscreceipt/index.vue
index b9d2d2f..6369376 100644
--- a/src/views/wls/miscreceipt/index.vue
+++ b/src/views/wls/miscreceipt/index.vue
@@ -120,14 +120,14 @@
label: $t('ui.actionTitle.create', ['鏉傞」鍏ュ簱鍗�']),
type: 'primary',
icon: ACTION_ICON.ADD,
- auth: ['mes:wm-misc-receipt:create'],
+ auth: ['mes:wm:misc-receipt:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
- auth: ['mes:wm-misc-receipt:export'],
+ auth: ['mes:wm:misc-receipt:export'],
onClick: handleExport,
},
]"
@@ -145,7 +145,7 @@
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
- auth: ['mes:wm-misc-receipt:update'],
+ auth: ['mes:wm:misc-receipt:update'],
ifShow: row.status === MesWmMiscReceiptStatusEnum.PREPARE,
onClick: handleEdit.bind(null, row),
},
@@ -154,7 +154,7 @@
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
- auth: ['mes:wm-misc-receipt:delete'],
+ auth: ['mes:wm:misc-receipt:delete'],
ifShow: row.status === MesWmMiscReceiptStatusEnum.PREPARE,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.code]),
@@ -164,7 +164,7 @@
{
label: '鎵ц鍏ュ簱',
type: 'link',
- auth: ['mes:wm-misc-receipt:finish'],
+ auth: ['mes:wm:misc-receipt:finish'],
ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVED,
onClick: handleFinish.bind(null, row),
},
@@ -172,7 +172,7 @@
label: '鍙栨秷',
type: 'link',
danger: true,
- auth: ['mes:wm-misc-receipt:cancel'],
+ auth: ['mes:wm:misc-receipt:cancel'],
ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVED,
popConfirm: {
title: '纭鍙栨秷璇ユ潅椤瑰叆搴撳崟鍚楋紵',
diff --git a/src/views/wls/returnvendor/index.vue b/src/views/wls/returnvendor/index.vue
index 895e494..e6d9fa4 100644
--- a/src/views/wls/returnvendor/index.vue
+++ b/src/views/wls/returnvendor/index.vue
@@ -176,7 +176,7 @@
{
label: '瀹屾垚閫�璐�',
type: 'link',
- auth: ['mes:wm-return-vendor:update-status'],
+ auth: ['mes:wm-return-vendor:finish'],
ifShow: row.status === MesWmReturnVendorStatusEnum.APPROVED,
onClick: handleFinish.bind(null, row),
},
--
Gitblit v1.9.3