feat(dashboard): 添加权限控制和能耗分析功能
- 在仪表板中集成 useHomeAccess 权限控制钩子
- 为预警面板、业务卡片和KPI卡片添加权限验证
- 实现路由导航权限检查和数据加载权限控制
- 为公司配置和IM存储添加重置方法
- 完善登出流程中的存储重置错误处理
- 新增能耗数据管理和分析模块
- 添加权限检查和登出测试脚本
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | # Product |
| | | |
| | | <!-- impeccable:product-schema 1 --> |
| | | |
| | | ## Platform |
| | | |
| | | web |
| | | |
| | | ## Operating Context |
| | | |
| | | ç°æ MOM 管çåå°ãæ¤æ¬¡è½èåè½é¢åéæµ·çä¸å®¶å京åä»å ä¼ä¸ï¼ä¸»è¦äº§åå
å«ççªåæ»è¡¥ååæµï¼ç¨æ·æä¾ï¼ã |
| | | |
| | | ## Capabilities and Constraints |
| | | |
| | | è½èç®¡çæ¯ç¬ç«æ¨¡åï¼æå¨æå¤©å½å
¥æ°´ãçµãè¸æ±½ä½¿ç¨ä¿¡æ¯ï¼æä¾å¢å æ¹æ¥åæ°æ®åæä¸¤ä¸ªåèåãç¨æ·å·²ç¡®è®¤æå¤©å½å
¥ãåºåã产åç±»å«ãç¨éåä¸ºæ¬æ¨¡åä¿¡æ¯ï¼ä¸å
³èå
¶ä»ä¸å¡æ¨¡åãåææ¯æææ¥/ææ±æ»ã |
| | | |
| | | ## Open Decisions |
| | | |
| | | ä¼ä¸æ£å¼åç§°ãå®é
åºååç§°ä¸è½æºåä»·æªæä¾ï¼åºå建议ä»
ç¨äºå½å
¥æç¤ºï¼ä»·æ ¼ç±ç¨æ·å¡«åã |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'); |
| | | })(); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'); |
| | | })(); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }); |
| | | } |
| | |
| | | } |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | |
| | | applyToPreferences(); |
| | | } |
| | | |
| | | function $reset() { |
| | | companyName.value = ''; |
| | | logoUrl.value = ''; |
| | | loginTitle.value = ''; |
| | | loginDescription.value = ''; |
| | | } |
| | | |
| | | return { |
| | | $reset, |
| | | companyName, |
| | | logoUrl, |
| | | loginTitle, |
| | |
| | | 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, |
| | | }, |
| | | ]" |
| | |
| | | |
| | | 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'; |
| | | |
| | |
| | | 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(); |
| | |
| | | }); |
| | | |
| | | function handleNavigate(name: string) { |
| | | if (!canNavigate(name)) return; |
| | | router.push({ name }); |
| | | } |
| | | |
| | | async function loadSummary() { |
| | | if (!canViewHome.value) return; |
| | | summary.value = await getHomeSummary(); |
| | | } |
| | | |
| | |
| | | </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"> |
| | |
| | | |
| | | import { getWorkOrderStatusDistribution } from '#/api/mes/home'; |
| | | |
| | | import { useHomeAccess } from '../use-home-access'; |
| | | |
| | | defineOptions({ name: 'DashboardAlertPanel' }); |
| | | |
| | | const props = defineProps<{ |
| | |
| | | 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(); |
| | |
| | | <!-- é¢è¦å¡çè¡ --> |
| | | <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')" |
| | | > |
| | |
| | | <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'; |
| | |
| | | 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<{ |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | async function loadSale() { |
| | | if (!canViewSale.value) return; |
| | | try { |
| | | const result = await getSaleOrderPage({ pageNo: 1, pageSize: 1 }); |
| | | saleCount.value = result.total ?? 0; |
| | |
| | | } |
| | | |
| | | async function loadStock() { |
| | | if (!canViewStock.value) return; |
| | | try { |
| | | const result = await getMaterialStockPage({ pageNo: 1, pageSize: 1 }); |
| | | stockCount.value = result.total ?? 0; |
| | |
| | | } |
| | | |
| | | async function loadBpmTodo() { |
| | | if (!canViewBpmTodo.value) return; |
| | | try { |
| | | const result = await getTaskTodoPage({ pageNo: 1, pageSize: 1 }); |
| | | bpmTodoCount.value = result.total ?? 0; |
| | |
| | | </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')" |
| | |
| | | </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')" |
| | |
| | | </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')" |
| | |
| | | </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')" |
| | |
| | | |
| | | 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; |
| | |
| | | <!-- ç产订å --> |
| | | <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"> |
| | |
| | | <!-- 仿¥äº§é --> |
| | | <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"> |
| | |
| | | <!-- è´¨éåæ ¼ç --> |
| | | <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"> |
| | |
| | | <!-- 设å¤è¿è¡ç --> |
| | | <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"> |
| | |
| | | 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); |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { useHomeAccess } from '../use-home-access'; |
| | | |
| | | defineOptions({ name: 'DashboardQuickActions' }); |
| | | |
| | |
| | | { 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)" |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }); |
| | | } |
| | |
| | | { |
| | | label: '交æ¥', |
| | | type: 'link', |
| | | auth: ['hrm:resignation:handover'], |
| | | auth: ['hrm:resignation:update'], |
| | | ifShow: row.status === 20 && row.handoverStatus !== 20, |
| | | popConfirm: { |
| | | title: row.handoverStatus === 0 ? '确认å¼å§äº¤æ¥åï¼' : 'ç¡®è®¤å®æäº¤æ¥åï¼', |
| | |
| | | }) |
| | | } |
| | | |
| | | function $reset() { |
| | | reset() |
| | | clearGroupCallCache() |
| | | } |
| | | |
| | | return { |
| | | $reset, |
| | | stage, |
| | | call, |
| | | incomingPayload, |
| | |
| | | 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, |
| | |
| | | { |
| | | label: '彿¡£', |
| | | type: 'link', |
| | | auth: ['mes:pd-project:archive'], |
| | | auth: ['mes:pd-project:update'], |
| | | ifShow: row.status === MesPdProjectStatusEnum.PENDING_ARCHIVE, |
| | | popConfirm: { |
| | | title: 'ç¡®è®¤å½æ¡£ï¼å½æ¡£åå°çæå°è´¦è®°å½ï¼ä»»å¡ä¸å¯åä¿®æ¹ã', |
| | |
| | | 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, |
| | | }, |
| | | ]" |
| | |
| | | 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), |
| | | }, |
| | | { |
| | |
| | | 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), |
| | |
| | | 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, |
| | | }, |
| | | ]" /> |
| | |
| | | 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), |
| | | }, |
| | | { |
| | |
| | | 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), |
| | |
| | | { |
| | | label: 'ç§»åº', |
| | | type: 'link', |
| | | auth: ['mes:wm-material-stock:move'], |
| | | auth: ['mes:wm-material-stock:update'], |
| | | ifShow: !row.frozen, |
| | | onClick: handleOpenStockMove.bind(null, row), |
| | | }, |
| | |
| | | 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, |
| | | }, |
| | | ]" |
| | |
| | | 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), |
| | | }, |
| | |
| | | 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]), |
| | |
| | | { |
| | | 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), |
| | | }, |
| | |
| | | label: 'åæ¶', |
| | | type: 'link', |
| | | danger: true, |
| | | auth: ['mes:wm-misc-receipt:cancel'], |
| | | auth: ['mes:wm:misc-receipt:cancel'], |
| | | ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVED, |
| | | popConfirm: { |
| | | title: 'ç¡®è®¤åæ¶è¯¥æé¡¹å
¥åºååï¼', |
| | |
| | | { |
| | | 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), |
| | | }, |