| | |
| | | export function checkEnergyAlerts(accessToken: string, date: string) { |
| | | return request<{ sent: number; failed: number }>(`/energy/alert/check${query({ date })}`, { method: "POST", header: header(accessToken) }); |
| | | } |
| | | /** 蓝羽纸业能耗管理:计量点抄表与预警采用 MES 能耗接口。 */ |
| | | export interface BlueEnergyMeterOption { id: number; code?: string; name?: string; energyType?: number; energyTypeName?: string; unit?: string; workshopName?: string; lineName?: string; } |
| | | export interface BlueEnergyMeter extends BlueEnergyMeterOption { ratio?: number; } |
| | | export interface BlueEnergyRecord { id?: number; meterId?: number; meterCode?: string; meterName?: string; energyType?: number; energyTypeName?: string; unit?: string; workshopName?: string; lineName?: string; ratio?: number; recordDate?: string; prevReading?: number; reading?: number; usageAmount?: number; price?: number; cost?: number; sourceName?: string; remark?: string; createTime?: string; } |
| | | export interface BlueEnergyAlert { id?: number; alertNo?: string; meterName?: string; energyTypeName?: string; alertTypeName?: string; alertLevel?: number; alertLevelName?: string; alertDate?: string; objectName?: string; actualValue?: number; thresholdValue?: number; unit?: string; content?: string; status?: number; statusName?: string; handleUserName?: string; handleTime?: string; handleRemark?: string; createTime?: string; } |
| | | export function getBlueEnergyMeterList(accessToken: string) { return request<BlueEnergyMeterOption[]>("/mes/em/meter/simple-list", { header: header(accessToken) }); } |
| | | export function getBlueEnergyMeter(accessToken: string, id: number) { return request<BlueEnergyMeter>(`/mes/em/meter/get${query({ id })}`, { header: header(accessToken) }); } |
| | | export function getBlueEnergyRecordPage(accessToken: string, params: { pageNo: number; pageSize: number; meterId?: number; recordDate?: string }) { return request<PageResult<BlueEnergyRecord>>(`/mes/em/record/page${query(params)}`, { header: header(accessToken) }); } |
| | | export function getBlueEnergyRecord(accessToken: string, id: number) { return request<BlueEnergyRecord>(`/mes/em/record/get${query({ id })}`, { header: header(accessToken) }); } |
| | | export function getBlueEnergyLastReading(accessToken: string, meterId: number, recordDate: string, excludeId?: number) { return request<{ prevReading?: number }>(`/mes/em/record/last-reading${query({ meterId, recordDate, excludeId })}`, { header: header(accessToken) }); } |
| | | export function createBlueEnergyRecord(accessToken: string, data: BlueEnergyRecord) { return request<number>("/mes/em/record/create", { method: "POST", data: data as Record<string, unknown>, header: header(accessToken) }); } |
| | | export function updateBlueEnergyRecord(accessToken: string, data: BlueEnergyRecord) { return request<boolean>("/mes/em/record/update", { method: "PUT", data: data as Record<string, unknown>, header: header(accessToken) }); } |
| | | export function deleteBlueEnergyRecord(accessToken: string, id: number) { return request<boolean>(`/mes/em/record/delete${query({ id })}`, { method: "DELETE", header: header(accessToken) }); } |
| | | export function getBlueEnergyAlertPage(accessToken: string, params: { pageNo: number; pageSize: number; status?: number; beginDate?: string; endDate?: string }) { return request<PageResult<BlueEnergyAlert>>(`/mes/em/alert/page${query(params)}`, { header: header(accessToken) }); } |
| | | export function getBlueEnergyAlert(accessToken: string, id: number) { return request<BlueEnergyAlert>(`/mes/em/alert/get${query({ id })}`, { header: header(accessToken) }); } |
| | | export function handleBlueEnergyAlert(accessToken: string, id: number, handleRemark: string) { return request<boolean>("/mes/em/alert/handle", { method: "PUT", data: { id, handleRemark }, header: header(accessToken) }); } |
| | | export function checkBlueEnergyAlerts(accessToken: string, beginDate: string, endDate: string) { return request<number>(`/mes/em/alert/check${query({ beginDate, endDate })}`, { method: "POST", header: header(accessToken) }); } |
| | |
| | | const materialOptions = computed(() => materials.value.map(item => materialOptionText(item))); |
| | | function recalculate() { |
| | | form.totalProductPrice = productTotal.value; |
| | | form.totalPrice = Number( |
| | | ( |
| | | (productTotal.value * (100 - Number(form.discountPercent ?? 0))) / |
| | | 100 |
| | | ).toFixed(2) |
| | | ); |
| | | const discountPercent = Number(form.discountPercent ?? 0); |
| | | form.totalPrice = Number((discountPercent > 0 ? productTotal.value * discountPercent / 100 : productTotal.value).toFixed(2)); |
| | | } |
| | | function changeDiscount(value: string) { |
| | | form.discountPercent = Number(value || 0); |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; |
| | | import { |
| | | checkEnergyAlerts, |
| | | deleteEnergyAlertRule, |
| | | getEnergyAlertLogs, |
| | | getEnergyAlertRecipients, |
| | | getEnergyAlertRules, |
| | | saveEnergyAlertRule, |
| | | type EnergyAlertLog, |
| | | type EnergyAlertMetric, |
| | | type EnergyAlertRecipient, |
| | | type EnergyAlertRule, |
| | | } from "../../../api/energy"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | import { |
| | | energyInfo, |
| | | energyName, |
| | | energyOptions, |
| | | isAmountMetric, |
| | | metricName, |
| | | metricOptions, |
| | | numberText, |
| | | productOptions, |
| | | today, |
| | | } from "../shared"; |
| | | |
| | | const { hasPermission, isLoggedIn, state } = useAuth(); |
| | | // 真实接口优先;仅本地临时排查接口不可用时手动改为 true。 |
| | | const USE_MOCK_ALERT_DATA = false; |
| | | const tab = ref<"rules" | "logs">("rules"); |
| | | const rules = ref<EnergyAlertRule[]>([]); |
| | | const logs = ref<EnergyAlertLog[]>([]); |
| | | const recipients = ref<EnergyAlertRecipient[]>([]); |
| | | const rulePage = reactive({ pageNo: 1, total: 0 }); |
| | | const logPage = reactive({ pageNo: 1, total: 0 }); |
| | | const logFilters = reactive<{ date?: string; status?: "SENT" | "FAILED" }>({}); |
| | | const keyword = ref(""); |
| | | const checkDate = ref(today()); |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | const checking = ref(false); |
| | | const showForm = ref(false); |
| | | const loadError = ref(""); |
| | | const form = reactive<EnergyAlertRule>(initial()); |
| | | const canCreate = computed(() => hasPermission("energy:alert:create")); |
| | | const canUpdate = computed(() => hasPermission("energy:alert:update")); |
| | | const canDelete = computed(() => hasPermission("energy:alert:delete")); |
| | | const canCheck = computed(() => hasPermission("energy:alert:check")); |
| | | const ruleHasMore = computed(() => rules.value.length < rulePage.total); |
| | | const logHasMore = computed(() => logs.value.length < logPage.total); |
| | | const filteredRules = computed(() => |
| | | rules.value.filter( |
| | | item => !keyword.value.trim() || item.name.includes(keyword.value.trim()) |
| | | ) |
| | | ); |
| | | const thresholdUnit = computed(() => |
| | | isAmountMetric(form.metric) ? "元" : energyInfo[form.energyType].unit |
| | | ); |
| | | const statusOptions = [ |
| | | { label: "发送成功", value: "SENT" }, |
| | | { label: "发送失败", value: "FAILED" }, |
| | | ]; |
| | | const mockRecipients: EnergyAlertRecipient[] = [ |
| | | { id: 1, nickname: "超级管理员" }, |
| | | { id: 2, nickname: "生产主管" }, |
| | | { id: 3, nickname: "设备主管" }, |
| | | ]; |
| | | const mockRules: EnergyAlertRule[] = [ |
| | | { |
| | | id: 1001, |
| | | name: "燕窝生产区每日用水预警", |
| | | energyType: "WATER", |
| | | usageArea: "燕窝生产区", |
| | | productCategory: "燕窝", |
| | | metric: "QUANTITY", |
| | | threshold: 120, |
| | | enabled: true, |
| | | recipientIds: [1, 2], |
| | | }, |
| | | { |
| | | id: 1002, |
| | | name: "动力站每月用电费用预警", |
| | | energyType: "ELECTRICITY", |
| | | usageArea: "动力站", |
| | | productCategory: "公用能耗", |
| | | metric: "MONTH_AMOUNT", |
| | | threshold: 8000, |
| | | enabled: true, |
| | | recipientIds: [1, 3], |
| | | }, |
| | | { |
| | | id: 1003, |
| | | name: "灭菌区蒸汽用量预警", |
| | | energyType: "STEAM", |
| | | usageArea: "灭菌区", |
| | | productCategory: "滋补品原浆", |
| | | metric: "QUANTITY", |
| | | threshold: 18, |
| | | enabled: false, |
| | | recipientIds: [2], |
| | | }, |
| | | ]; |
| | | const mockLogs: EnergyAlertLog[] = [ |
| | | { |
| | | id: 2001, |
| | | ruleName: "燕窝生产区每日用水预警", |
| | | recordDate: today(), |
| | | energyType: "WATER", |
| | | scope: "燕窝生产区 / 燕窝", |
| | | metric: "QUANTITY", |
| | | periodType: "DAY", |
| | | actualValue: 132.5, |
| | | threshold: 120, |
| | | recipientName: "生产主管", |
| | | status: "SENT", |
| | | errorMessage: "", |
| | | createTime: `${today()} 09:20`, |
| | | }, |
| | | { |
| | | id: 2002, |
| | | ruleName: "动力站每月用电费用预警", |
| | | recordDate: today(), |
| | | energyType: "ELECTRICITY", |
| | | scope: "动力站 / 公用能耗", |
| | | metric: "MONTH_AMOUNT", |
| | | periodType: "MONTH", |
| | | actualValue: 8420.6, |
| | | threshold: 8000, |
| | | recipientName: "设备主管", |
| | | status: "SENT", |
| | | errorMessage: "", |
| | | createTime: `${today()} 09:21`, |
| | | }, |
| | | { |
| | | id: 2003, |
| | | ruleName: "灭菌区蒸汽用量预警", |
| | | recordDate: today(), |
| | | energyType: "STEAM", |
| | | scope: "灭菌区 / 滋补品原浆", |
| | | metric: "QUANTITY", |
| | | periodType: "DAY", |
| | | actualValue: 21.2, |
| | | threshold: 18, |
| | | recipientName: "生产主管", |
| | | status: "FAILED", |
| | | errorMessage: "模拟通知发送失败", |
| | | createTime: `${today()} 09:22`, |
| | | }, |
| | | ]; |
| | | |
| | | function initial(): EnergyAlertRule { |
| | | return { |
| | | name: "", |
| | | energyType: "WATER", |
| | | metric: "QUANTITY", |
| | | threshold: 1, |
| | | enabled: true, |
| | | recipientIds: [], |
| | | usageArea: "", |
| | | productCategory: undefined, |
| | | }; |
| | | } |
| | | function resetForm(data?: EnergyAlertRule) { |
| | | Object.keys(form).forEach( |
| | | key => delete (form as unknown as Record<string, unknown>)[key] |
| | | ); |
| | | Object.assign( |
| | | form, |
| | | data ? { ...data, recipientIds: [...data.recipientIds] } : initial() |
| | | ); |
| | | } |
| | | function pickEnergy(index: number) { |
| | | form.energyType = energyOptions[index].value; |
| | | } |
| | | function pickMetric(index: number) { |
| | | form.metric = metricOptions[index].value; |
| | | } |
| | | function pickProduct(index: number) { |
| | | form.productCategory = productOptions[index]; |
| | | } |
| | | function pickStatus(index: number) { |
| | | logFilters.status = statusOptions[index].value as "SENT" | "FAILED"; |
| | | } |
| | | function toggleRecipient(id: number) { |
| | | form.recipientIds = form.recipientIds.includes(id) |
| | | ? form.recipientIds.filter(item => item !== id) |
| | | : form.recipientIds.concat(id); |
| | | } |
| | | function recipientNames(ids: number[]) { |
| | | return ( |
| | | ids |
| | | .map( |
| | | id => |
| | | recipients.value.find(user => user.id === id)?.nickname || |
| | | `用户 ${id}` |
| | | ) |
| | | .join("、") || "--" |
| | | ); |
| | | } |
| | | function metricUnit(metric: EnergyAlertMetric, energyType = form.energyType) { |
| | | return isAmountMetric(metric) ? "元" : energyInfo[energyType].unit; |
| | | } |
| | | function validForm() { |
| | | if (!form.name?.trim()) return "请输入规则名称"; |
| | | if (!(Number(form.threshold) > 0)) return "阈值必须大于零"; |
| | | if (!form.recipientIds.length || form.recipientIds.length > 50) |
| | | return "请选择 1 至 50 位接收人"; |
| | | return ""; |
| | | } |
| | | async function loadRecipients() { |
| | | if (USE_MOCK_ALERT_DATA) { |
| | | recipients.value = mockRecipients; |
| | | return; |
| | | } |
| | | if (!state.accessToken || recipients.value.length) return; |
| | | recipients.value = await getEnergyAlertRecipients(state.accessToken); |
| | | } |
| | | async function openForm(row?: EnergyAlertRule) { |
| | | if (!USE_MOCK_ALERT_DATA && !state.accessToken) return; |
| | | try { |
| | | await loadRecipients(); |
| | | resetForm(row); |
| | | showForm.value = true; |
| | | } catch (error) { |
| | | uni.showToast({ |
| | | title: error instanceof Error ? error.message : "接收人加载失败", |
| | | icon: "none", |
| | | }); |
| | | } |
| | | } |
| | | async function saveRule() { |
| | | if ((!USE_MOCK_ALERT_DATA && !state.accessToken) || saving.value) return; |
| | | const warning = validForm(); |
| | | if (warning) return uni.showToast({ title: warning, icon: "none" }); |
| | | saving.value = true; |
| | | try { |
| | | if (USE_MOCK_ALERT_DATA) { |
| | | const payload = { |
| | | ...form, |
| | | id: form.id || Date.now(), |
| | | name: form.name.trim(), |
| | | usageArea: form.usageArea?.trim() || undefined, |
| | | threshold: Number(form.threshold), |
| | | }; |
| | | const index = mockRules.findIndex(item => item.id === payload.id); |
| | | if (index >= 0) mockRules.splice(index, 1, payload); |
| | | else mockRules.unshift(payload); |
| | | uni.showToast({ title: "保存成功", icon: "success" }); |
| | | showForm.value = false; |
| | | await loadRules(true); |
| | | return; |
| | | } |
| | | await saveEnergyAlertRule(state.accessToken, { |
| | | ...form, |
| | | name: form.name.trim(), |
| | | usageArea: form.usageArea?.trim() || undefined, |
| | | threshold: Number(form.threshold), |
| | | }); |
| | | uni.showToast({ title: "保存成功", icon: "success" }); |
| | | showForm.value = false; |
| | | await loadRules(true); |
| | | } catch (error) { |
| | | uni.showToast({ |
| | | title: error instanceof Error ? error.message : "保存失败", |
| | | icon: "none", |
| | | }); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | } |
| | | async function remove(row: EnergyAlertRule) { |
| | | if ((!USE_MOCK_ALERT_DATA && !state.accessToken) || !row.id) return; |
| | | const ok = await new Promise<boolean>(resolve => |
| | | uni.showModal({ |
| | | title: "确认删除", |
| | | content: "删除此规则?历史通知记录仍会保留。", |
| | | success: res => resolve(res.confirm), |
| | | }) |
| | | ); |
| | | if (!ok) return; |
| | | try { |
| | | if (USE_MOCK_ALERT_DATA) { |
| | | const index = mockRules.findIndex(item => item.id === row.id); |
| | | if (index >= 0) mockRules.splice(index, 1); |
| | | uni.showToast({ title: "删除成功", icon: "success" }); |
| | | await loadRules(true); |
| | | return; |
| | | } |
| | | await deleteEnergyAlertRule(state.accessToken, row.id); |
| | | uni.showToast({ title: "删除成功", icon: "success" }); |
| | | await loadRules(true); |
| | | } catch (error) { |
| | | uni.showToast({ |
| | | title: error instanceof Error ? error.message : "删除失败", |
| | | icon: "none", |
| | | }); |
| | | } |
| | | } |
| | | async function checkNow() { |
| | | if ((!USE_MOCK_ALERT_DATA && !state.accessToken) || checking.value) return; |
| | | if (!checkDate.value || checkDate.value > today()) |
| | | return uni.showToast({ title: "请选择今天或之前的日期", icon: "none" }); |
| | | checking.value = true; |
| | | try { |
| | | if (USE_MOCK_ALERT_DATA) { |
| | | mockLogs.unshift({ |
| | | id: Date.now(), |
| | | ruleName: "手动检查模拟记录", |
| | | recordDate: checkDate.value, |
| | | energyType: "WATER", |
| | | scope: "全部区域 / 全部产品", |
| | | metric: "QUANTITY", |
| | | periodType: "DAY", |
| | | actualValue: 126, |
| | | threshold: 120, |
| | | recipientName: "超级管理员", |
| | | status: "SENT", |
| | | errorMessage: "", |
| | | createTime: `${checkDate.value} 10:00`, |
| | | }); |
| | | uni.showToast({ title: "检查完成:发送1条,失败0条", icon: "success" }); |
| | | tab.value = "logs"; |
| | | logFilters.date = checkDate.value; |
| | | await loadLogs(true); |
| | | return; |
| | | } |
| | | const result = await checkEnergyAlerts(state.accessToken, checkDate.value); |
| | | uni.showToast({ |
| | | title: `检查完成:发送${result.sent}条,失败${result.failed}条`, |
| | | icon: result.failed ? "none" : "success", |
| | | }); |
| | | tab.value = "logs"; |
| | | logFilters.date = checkDate.value; |
| | | await loadLogs(true); |
| | | } catch (error) { |
| | | uni.showToast({ |
| | | title: error instanceof Error ? error.message : "检查失败", |
| | | icon: "none", |
| | | }); |
| | | } finally { |
| | | checking.value = false; |
| | | } |
| | | } |
| | | async function loadRules(reset = false) { |
| | | if ((!USE_MOCK_ALERT_DATA && !state.accessToken) || loading.value) return; |
| | | if (reset) { |
| | | rulePage.pageNo = 1; |
| | | rules.value = []; |
| | | } |
| | | loading.value = true; |
| | | loadError.value = ""; |
| | | try { |
| | | if (USE_MOCK_ALERT_DATA) { |
| | | const pageSize = 10; |
| | | const start = (rulePage.pageNo - 1) * pageSize; |
| | | const list = mockRules |
| | | .slice(start, start + pageSize) |
| | | .map(item => ({ ...item, recipientIds: [...item.recipientIds] })); |
| | | rulePage.total = mockRules.length; |
| | | rules.value = reset ? list : rules.value.concat(list); |
| | | rulePage.pageNo += 1; |
| | | return; |
| | | } |
| | | const result = await getEnergyAlertRules(state.accessToken, { |
| | | pageNo: rulePage.pageNo, |
| | | pageSize: 10, |
| | | }); |
| | | rulePage.total = result.total || 0; |
| | | rules.value = reset |
| | | ? result.list || [] |
| | | : rules.value.concat(result.list || []); |
| | | rulePage.pageNo += 1; |
| | | } catch (error) { |
| | | loadError.value = |
| | | error instanceof Error ? error.message : "预警规则加载失败"; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | async function loadLogs(reset = false) { |
| | | if ((!USE_MOCK_ALERT_DATA && !state.accessToken) || loading.value) return; |
| | | if (reset) { |
| | | logPage.pageNo = 1; |
| | | logs.value = []; |
| | | } |
| | | loading.value = true; |
| | | loadError.value = ""; |
| | | try { |
| | | if (USE_MOCK_ALERT_DATA) { |
| | | const pageSize = 10; |
| | | const source = mockLogs.filter( |
| | | item => |
| | | (!logFilters.date || item.recordDate === logFilters.date) && |
| | | (!logFilters.status || item.status === logFilters.status) |
| | | ); |
| | | const start = (logPage.pageNo - 1) * pageSize; |
| | | const list = source.slice(start, start + pageSize); |
| | | logPage.total = source.length; |
| | | logs.value = reset ? list : logs.value.concat(list); |
| | | logPage.pageNo += 1; |
| | | return; |
| | | } |
| | | const result = await getEnergyAlertLogs(state.accessToken, { |
| | | pageNo: logPage.pageNo, |
| | | pageSize: 10, |
| | | date: logFilters.date, |
| | | status: logFilters.status, |
| | | }); |
| | | logPage.total = result.total || 0; |
| | | logs.value = reset |
| | | ? result.list || [] |
| | | : logs.value.concat(result.list || []); |
| | | logPage.pageNo += 1; |
| | | } catch (error) { |
| | | loadError.value = |
| | | error instanceof Error ? error.message : "通知记录加载失败"; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | function load(reset = false) { |
| | | return tab.value === "rules" ? loadRules(reset) : loadLogs(reset); |
| | | } |
| | | function switchTab(value: "rules" | "logs") { |
| | | tab.value = value; |
| | | load(true); |
| | | } |
| | | function clearLogFilters() { |
| | | logFilters.date = undefined; |
| | | logFilters.status = undefined; |
| | | loadLogs(true); |
| | | } |
| | | |
| | | onShow(async () => { |
| | | if (isLoggedIn.value) { |
| | | await load(true); |
| | | loadRecipients().catch(() => undefined); |
| | | } |
| | | }); |
| | | onPullDownRefresh(async () => { |
| | | await load(true); |
| | | uni.stopPullDownRefresh(); |
| | | }); |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; |
| | | import { checkBlueEnergyAlerts, getBlueEnergyAlert, getBlueEnergyAlertPage, handleBlueEnergyAlert, type BlueEnergyAlert } from "../../../api/energy"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | const { hasPermission, isLoggedIn, state } = useAuth(); const rows = ref<BlueEnergyAlert[]>([]), pageNo = ref(1), total = ref(0), loading = ref(false), checking = ref(false), handling = ref(false), loadError = ref(""); const filters = reactive({ beginDate: "", endDate: "", status: undefined as number | undefined }); const dialog = ref<BlueEnergyAlert>(); const handleRemark = ref(""); const showCheck = ref(false); const checkRange = reactive({ beginDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0,10), endDate: new Date().toISOString().slice(0,10) }); |
| | | const canQuery = computed(() => isLoggedIn.value), canHandle = computed(() => hasPermission("mes:em-alert:handle")), hasMore = computed(() => rows.value.length < total.value); const statusText = (value?: number) => value === 0 ? "待处理" : "已处理"; const levelClass = (level?: number) => level === 3 ? "red" : level === 2 ? "orange" : "blue"; |
| | | async function load(reset=false) { if (!state.accessToken || loading.value) return; if(reset){pageNo.value=1;rows.value=[];} loading.value=true;loadError.value="";try{const data=await getBlueEnergyAlertPage(state.accessToken,{pageNo:pageNo.value,pageSize:15,status:filters.status,beginDate:filters.beginDate||undefined,endDate:filters.endDate||undefined});total.value=data.total||0;rows.value=reset?data.list||[]:rows.value.concat(data.list||[]);pageNo.value+=1;}catch(error){loadError.value=error instanceof Error?error.message:"能耗预警加载失败";}finally{loading.value=false;}} |
| | | async function openDetail(row: BlueEnergyAlert){if(!state.accessToken||!row.id)return;try{dialog.value=await getBlueEnergyAlert(state.accessToken,row.id);handleRemark.value=dialog.value.handleRemark||"";}catch(error){uni.showToast({title:error instanceof Error?error.message:"详情加载失败",icon:"none"});}} |
| | | async function handle(){if(!state.accessToken||!dialog.value?.id||!handleRemark.value.trim())return uni.showToast({title:"请填写处理说明",icon:"none"});handling.value=true;try{await handleBlueEnergyAlert(state.accessToken,dialog.value.id,handleRemark.value.trim());uni.showToast({title:"预警已处理",icon:"success"});dialog.value=undefined;load(true);}catch(error){uni.showToast({title:error instanceof Error?error.message:"处理失败",icon:"none"});}finally{handling.value=false;}} |
| | | async function check(){if(!state.accessToken)return;checking.value=true;try{const count=await checkBlueEnergyAlerts(state.accessToken,checkRange.beginDate,checkRange.endDate);uni.showToast({title:`检测完成,共检测 ${count||0} 条`,icon:"none"});showCheck.value=false;load(true);}catch(error){uni.showToast({title:error instanceof Error?error.message:"检测失败",icon:"none"});}finally{checking.value=false;}} |
| | | onShow(()=>load(true));onPullDownRefresh(async()=>{await load(true);uni.stopPullDownRefresh();}); |
| | | </script> |
| | | |
| | | <template> |
| | | <view class="page"> |
| | | <template v-if="isLoggedIn"> |
| | | <view class="tip">每日指标按天汇总,每月指标按自然月汇总;同一规则、统计周期和接收人只成功通知一次。</view> |
| | | <view class="check-card"> |
| | | <view class="field"><text>检查日期</text> |
| | | <picker mode="date" |
| | | :value="checkDate" |
| | | :end="today()" |
| | | @change="checkDate=$event.detail.value"> |
| | | <view class="picker"><text>{{ checkDate }}</text><text>›</text></view> |
| | | </picker> |
| | | </view> |
| | | <button v-if="canCheck" |
| | | :loading="checking" |
| | | :disabled="checking" |
| | | @click="checkNow">检查预警 / 重试失败通知</button> |
| | | </view> |
| | | <view class="tabs"><text :class="{active:tab==='rules'}" |
| | | @click="switchTab('rules')">预警规则</text><text :class="{active:tab==='logs'}" |
| | | @click="switchTab('logs')">通知记录</text></view> |
| | | <template v-if="tab==='rules'"> |
| | | <view class="search"><input v-model="keyword" |
| | | placeholder="搜索规则名称" |
| | | confirm-type="search" /><text v-if="keyword" |
| | | @click="keyword=''">清除</text></view> |
| | | <scroll-view class="content" |
| | | scroll-y |
| | | lower-threshold="80" |
| | | @scrolltolower="ruleHasMore&&loadRules()"> |
| | | <view v-if="loadError" |
| | | class="state error" |
| | | @click="loadRules(true)">{{ loadError }},点击重试</view> |
| | | <template v-else-if="filteredRules.length"> |
| | | <view v-for="row in filteredRules" |
| | | :key="row.id" |
| | | class="row"> |
| | | <view class="head"> |
| | | <view><text class="name">{{ row.name }}</text><text>{{ row.usageArea || '全部区域' }} / {{ row.productCategory || '全部产品' }}</text></view> |
| | | <view class="status-wrap"><text :class="['status', row.enabled ? 'tag-green' : 'tag-gray']">{{ row.enabled ? '启用' : '停用' }}</text></view> |
| | | </view> |
| | | <view class="tags"><text :class="['pill', energyInfo[row.energyType].tone]">{{ energyName(row.energyType) }}</text><text class="pill tag-blue">{{ metricName(row.metric) }}</text></view> |
| | | <view class="grid"> |
| | | <text>阈值:{{ numberText(row.threshold) }} {{ metricUnit(row.metric, row.energyType) }}</text> |
| | | <text>接收人:{{ recipientNames(row.recipientIds) }}</text> |
| | | </view> |
| | | <view class="actions"> |
| | | <button v-if="canUpdate" |
| | | @click="openForm(row)">编辑</button> |
| | | <button v-if="canDelete" |
| | | class="danger" |
| | | @click="remove(row)">删除</button> |
| | | </view> |
| | | </view> |
| | | <text class="more">{{ loading ? '加载中...' : ruleHasMore ? '上拉加载更多' : '没有更多了' }}</text> |
| | | </template> |
| | | <view v-else |
| | | class="state">{{ loading ? '加载中...' : '暂无预警规则' }}</view> |
| | | </scroll-view> |
| | | <button v-if="canCreate" |
| | | class="fab" |
| | | @click="openForm()"> |
| | | <view /> |
| | | </button> |
| | | </template> |
| | | <template v-else> |
| | | <view class="filters"> |
| | | <view class="field"><text>统计日期</text> |
| | | <picker mode="date" |
| | | :value="logFilters.date || ''" |
| | | :end="today()" |
| | | @change="logFilters.date=$event.detail.value"> |
| | | <view class="picker"><text>{{ logFilters.date || '全部日期' }}</text><text>›</text></view> |
| | | </picker> |
| | | </view> |
| | | <view class="field"><text>发送结果</text> |
| | | <picker :range="statusOptions" |
| | | range-key="label" |
| | | @change="pickStatus(Number($event.detail.value))"> |
| | | <view class="picker"><text>{{ logFilters.status === 'SENT' ? '发送成功' : logFilters.status === 'FAILED' ? '发送失败' : '全部结果' }}</text><text>›</text></view> |
| | | </picker> |
| | | </view> |
| | | <view class="filter-actions"><button @click="loadLogs(true)">查询</button><button @click="clearLogFilters">重置</button></view> |
| | | </view> |
| | | <scroll-view class="content" |
| | | scroll-y |
| | | lower-threshold="80" |
| | | @scrolltolower="logHasMore&&loadLogs()"> |
| | | <view v-if="loadError" |
| | | class="state error" |
| | | @click="loadLogs(true)">{{ loadError }},点击重试</view> |
| | | <template v-else-if="logs.length"> |
| | | <view v-for="row in logs" |
| | | :key="row.id" |
| | | class="row"> |
| | | <view class="head"> |
| | | <view><text class="name">{{ row.ruleName }}</text><text>{{ row.periodType === 'MONTH' ? row.recordDate.slice(0,7) : row.recordDate }} · {{ metricName(row.metric) }}</text></view> |
| | | <view class="status-wrap"><text :class="['status', row.status === 'SENT' ? 'tag-green' : 'tag-red']">{{ row.status === 'SENT' ? '发送成功' : '发送失败' }}</text></view> |
| | | </view> |
| | | <view class="grid"> |
| | | <text>能源 / 范围:{{ energyName(row.energyType) }} / {{ row.scope || '--' }}</text> |
| | | <text>实际 / 阈值:{{ numberText(row.actualValue) }} / {{ numberText(row.threshold) }} {{ metricUnit(row.metric, row.energyType) }}</text> |
| | | <text>接收人:{{ row.recipientName || '--' }}</text> |
| | | <text>创建时间:{{ row.createTime || '--' }}</text> |
| | | </view> |
| | | <view v-if="row.errorMessage" |
| | | class="reason">失败原因:{{ row.errorMessage }}</view> |
| | | </view> |
| | | <text class="more">{{ loading ? '加载中...' : logHasMore ? '上拉加载更多' : '没有更多了' }}</text> |
| | | </template> |
| | | <view v-else |
| | | class="state">{{ loading ? '加载中...' : '暂无预警通知记录' }}</view> |
| | | </scroll-view> |
| | | </template> |
| | | </template> |
| | | <view v-else |
| | | class="state">登录后查看能耗预警</view> |
| | | <view v-if="showForm" |
| | | class="modal-mask"> |
| | | <view class="modal"> |
| | | <view class="modal-head"><text>{{ form.id ? '编辑预警规则' : '新增预警规则' }}</text><text @click="showForm=false">×</text></view> |
| | | <scroll-view class="modal-body" |
| | | scroll-y> |
| | | <view class="field"><text>规则名称 <text class="required">*</text></text><input v-model="form.name" |
| | | placeholder="例如:燕窝生产区每日用水预警" |
| | | maxlength="100" /></view> |
| | | <view class="field"><text>能源</text> |
| | | <picker :range="energyOptions" |
| | | range-key="label" |
| | | @change="pickEnergy(Number($event.detail.value))"> |
| | | <view class="picker"><text>{{ energyName(form.energyType) }}</text><text>›</text></view> |
| | | </picker> |
| | | </view> |
| | | <view class="field"><text>统计指标</text> |
| | | <picker :range="metricOptions" |
| | | range-key="label" |
| | | @change="pickMetric(Number($event.detail.value))"> |
| | | <view class="picker"><text>{{ metricName(form.metric) }}</text><text>›</text></view> |
| | | </picker> |
| | | </view> |
| | | <view class="field"><text>使用区域</text><input v-model="form.usageArea" |
| | | placeholder="留空统计全部区域" |
| | | maxlength="100" /></view> |
| | | <view class="field"><text>产品类别</text> |
| | | <picker :range="productOptions" |
| | | @change="pickProduct(Number($event.detail.value))"> |
| | | <view class="picker"><text>{{ form.productCategory || '全部产品' }}</text><text>›</text></view> |
| | | </picker> |
| | | </view> |
| | | <view class="field"><text>{{ form.metric.startsWith('MONTH_') ? '每月' : '每日' }}阈值({{ thresholdUnit }}) <text class="required">*</text></text><input :value="String(form.threshold ?? '')" |
| | | type="digit" |
| | | placeholder="大于此值时通知" |
| | | @input="form.threshold=Number($event.detail.value || 0)" /></view> |
| | | <view class="field"><text>启用规则</text> |
| | | <switch :checked="form.enabled" |
| | | @change="form.enabled=$event.detail.value" /> |
| | | </view> |
| | | <view class="recipient-title">通知接收人 <text class="required">*</text></view> |
| | | <view class="recipient-list"> |
| | | <text v-for="user in recipients" |
| | | :key="user.id" |
| | | :class="{active:form.recipientIds.includes(user.id)}" |
| | | @click="toggleRecipient(user.id)">{{ user.nickname }}</text> |
| | | </view> |
| | | </scroll-view> |
| | | <view class="modal-actions"><button @click="showForm=false">取消</button><button :loading="saving" |
| | | :disabled="saving" |
| | | @click="saveRule">确认</button></view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <style lang="scss" scoped> |
| | | .page { |
| | | height: 100vh; |
| | | display: flex; |
| | | flex-direction: column; |
| | | padding: 0 30rpx; |
| | | box-sizing: border-box; |
| | | overflow: hidden; |
| | | background: #f3f6fb; |
| | | } |
| | | .tip { |
| | | flex: none; |
| | | margin: 20rpx 0 14rpx; |
| | | padding: 18rpx 22rpx; |
| | | color: #66778c; |
| | | font-size: 24rpx; |
| | | line-height: 1.55; |
| | | background: #fff7e8; |
| | | border: 1rpx solid #f4dcad; |
| | | border-radius: 16rpx; |
| | | } |
| | | .check-card, |
| | | .filters { |
| | | flex: none; |
| | | margin-bottom: 16rpx; |
| | | overflow: hidden; |
| | | background: #fff; |
| | | border-radius: 16rpx; |
| | | box-shadow: 0 8rpx 20rpx rgba(43, 69, 107, 0.06); |
| | | } |
| | | .check-card button { |
| | | margin: 18rpx 22rpx; |
| | | color: #fff; |
| | | font-size: 25rpx; |
| | | background: var(--color-primary, #1769d8); |
| | | border-radius: 12rpx; |
| | | } |
| | | .check-card button::after, |
| | | .filter-actions button::after, |
| | | .actions button::after, |
| | | .fab::after, |
| | | .modal-actions button::after { |
| | | border: 0; |
| | | } |
| | | .tabs { |
| | | display: flex; |
| | | flex: none; |
| | | gap: 14rpx; |
| | | margin-bottom: 16rpx; |
| | | } |
| | | .tabs text { |
| | | padding: 12rpx 24rpx; |
| | | color: #52677f; |
| | | font-size: 25rpx; |
| | | background: #fff; |
| | | border-radius: 999rpx; |
| | | } |
| | | .tabs .active { |
| | | color: var(--color-primary, #1769d8); |
| | | font-weight: 600; |
| | | background: #eaf2ff; |
| | | } |
| | | .search { |
| | | display: flex; |
| | | align-items: center; |
| | | flex: none; |
| | | height: 78rpx; |
| | | margin-bottom: 16rpx; |
| | | padding: 0 20rpx; |
| | | background: #fff; |
| | | border-radius: 16rpx; |
| | | box-shadow: 0 8rpx 20rpx rgba(43, 69, 107, 0.06); |
| | | } |
| | | .search input { |
| | | min-width: 0; |
| | | flex: 1; |
| | | color: #1e3049; |
| | | font-size: 27rpx; |
| | | } |
| | | .search text { |
| | | flex: none; |
| | | color: var(--color-primary, #1769d8); |
| | | font-size: 24rpx; |
| | | } |
| | | .content { |
| | | height: 0; |
| | | flex: 1; |
| | | padding-bottom: 132rpx; |
| | | box-sizing: border-box; |
| | | } |
| | | .field { |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 82rpx; |
| | | padding: 0 22rpx; |
| | | gap: 18rpx; |
| | | border-bottom: 1rpx solid #eef1f5; |
| | | } |
| | | .field > text:first-child { |
| | | width: 190rpx; |
| | | flex: none; |
| | | color: #33455d; |
| | | font-size: 25rpx; |
| | | } |
| | | .field input, |
| | | .field picker, |
| | | .picker { |
| | | min-width: 0; |
| | | flex: 1; |
| | | color: #1e3049; |
| | | font-size: 25rpx; |
| | | } |
| | | .field input { |
| | | text-align: right; |
| | | } |
| | | .picker { |
| | | display: flex; |
| | | width: 100%; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12rpx; |
| | | color: #65758a; |
| | | } |
| | | .picker text:first-child { |
| | | min-width: 0; |
| | | flex: 1; |
| | | overflow: hidden; |
| | | text-align: right; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .picker text:last-child { |
| | | flex: none; |
| | | width: 24rpx; |
| | | color: #8092a7; |
| | | font-size: 34rpx; |
| | | text-align: right; |
| | | } |
| | | .filter-actions { |
| | | display: flex; |
| | | gap: 16rpx; |
| | | padding: 18rpx 22rpx; |
| | | } |
| | | .filter-actions button { |
| | | flex: 1; |
| | | margin: 0; |
| | | color: var(--color-primary, #1769d8); |
| | | font-size: 25rpx; |
| | | background: var(--color-primary-soft, #edf5ff); |
| | | border-radius: 12rpx; |
| | | } |
| | | .filter-actions button:first-child { |
| | | color: #fff; |
| | | background: var(--color-primary, #1769d8); |
| | | } |
| | | .row { |
| | | padding: 24rpx 22rpx 20rpx; |
| | | margin-bottom: 18rpx; |
| | | background: #fff; |
| | | border-radius: 16rpx; |
| | | box-shadow: 0 10rpx 24rpx rgba(43, 69, 107, 0.08); |
| | | } |
| | | .head { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 16rpx; |
| | | } |
| | | .head > view:first-child { |
| | | display: flex; |
| | | min-width: 0; |
| | | flex: 1; |
| | | flex-direction: column; |
| | | gap: 8rpx; |
| | | } |
| | | .head > view:first-child text:last-child { |
| | | overflow: hidden; |
| | | color: #78879b; |
| | | font-size: 23rpx; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .name { |
| | | overflow: hidden; |
| | | color: #20334c; |
| | | font-size: 30rpx; |
| | | font-weight: 600; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .status-wrap { |
| | | display: flex; |
| | | align-items: center; |
| | | flex: none; |
| | | height: 48rpx; |
| | | } |
| | | .status, |
| | | .pill { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | height: 36rpx; |
| | | padding: 0 16rpx; |
| | | font-size: 22rpx; |
| | | border-radius: 999rpx; |
| | | white-space: nowrap; |
| | | } |
| | | .tags { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 10rpx; |
| | | margin-top: 14rpx; |
| | | } |
| | | .grid { |
| | | display: grid; |
| | | grid-template-columns: 1fr; |
| | | gap: 10rpx; |
| | | padding: 16rpx 0; |
| | | margin-top: 14rpx; |
| | | color: #66778c; |
| | | font-size: 23rpx; |
| | | border-top: 1rpx solid #edf1f6; |
| | | border-bottom: 1rpx solid #edf1f6; |
| | | } |
| | | .grid text { |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .reason { |
| | | margin-top: 14rpx; |
| | | padding: 14rpx 16rpx; |
| | | color: #c65050; |
| | | font-size: 23rpx; |
| | | line-height: 1.5; |
| | | background: #fff7f7; |
| | | border-radius: 10rpx; |
| | | } |
| | | .actions { |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | gap: 12rpx; |
| | | padding-top: 16rpx; |
| | | } |
| | | .actions button { |
| | | min-height: 50rpx; |
| | | padding: 0 16rpx; |
| | | margin: 0; |
| | | color: var(--color-primary, #1769d8); |
| | | font-size: 22rpx; |
| | | line-height: 48rpx; |
| | | background: var(--color-primary-soft, #f5f8ff); |
| | | border: 1rpx solid var(--color-primary-border, #8ab3ee); |
| | | border-radius: 8rpx; |
| | | } |
| | | .actions .danger { |
| | | color: #c65050; |
| | | background: #fff7f7; |
| | | border-color: #e3a0a0; |
| | | } |
| | | .more, |
| | | .state { |
| | | display: block; |
| | | padding: 35rpx; |
| | | color: #8795a8; |
| | | font-size: 26rpx; |
| | | text-align: center; |
| | | } |
| | | .error { |
| | | color: #c3654d; |
| | | } |
| | | .required { |
| | | color: #e34b4b; |
| | | } |
| | | .tag-blue { |
| | | color: var(--color-primary, #1769d8); |
| | | background: var(--color-primary-soft, #eef6ff); |
| | | border: 1rpx solid var(--color-primary-border, #c9e1ff); |
| | | } |
| | | .tag-orange { |
| | | color: #b06a00; |
| | | background: #fff6df; |
| | | border: 1rpx solid #f0d390; |
| | | } |
| | | .tag-green { |
| | | color: #0e9663; |
| | | background: #e9fbf2; |
| | | border: 1rpx solid #b8eed4; |
| | | } |
| | | .tag-red { |
| | | color: #c65050; |
| | | background: #fff7f7; |
| | | border: 1rpx solid #efb7b7; |
| | | } |
| | | .tag-gray { |
| | | color: #7a8797; |
| | | background: #f4f6f8; |
| | | border: 1rpx solid #dbe2ea; |
| | | } |
| | | .fab { |
| | | position: fixed; |
| | | right: 30rpx; |
| | | bottom: calc(32rpx + env(safe-area-inset-bottom)); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 104rpx; height: 104rpx; min-height: 104rpx; |
| | | padding: 0; |
| | | margin: 0; |
| | | background: var(--color-primary, #1769d8); |
| | | border-radius: 50%; |
| | | box-shadow: 0 12rpx 24rpx rgba(23, 105, 216, 0.3); |
| | | } |
| | | .fab view, |
| | | .fab view::before { |
| | | display: block; |
| | | width: 38rpx; |
| | | height: 8rpx; |
| | | background: #fff; |
| | | border-radius: 4rpx; |
| | | content: ""; |
| | | } |
| | | .fab view { |
| | | position: relative; |
| | | } |
| | | .fab view::before { |
| | | position: absolute; |
| | | transform: rotate(90deg); |
| | | } |
| | | .modal-mask { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 20; |
| | | display: flex; |
| | | align-items: flex-end; |
| | | background: rgba(21, 34, 50, 0.42); |
| | | } |
| | | .modal { |
| | | width: 100%; |
| | | max-height: 88vh; |
| | | display: flex; |
| | | flex-direction: column; |
| | | overflow: hidden; |
| | | background: #f3f6fb; |
| | | border-radius: 24rpx 24rpx 0 0; |
| | | } |
| | | .modal-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 28rpx 30rpx; |
| | | color: #20334c; |
| | | font-size: 31rpx; |
| | | font-weight: 700; |
| | | background: #fff; |
| | | } |
| | | .modal-head text:last-child { |
| | | color: #7f8fa4; |
| | | font-size: 42rpx; |
| | | font-weight: 400; |
| | | } |
| | | .modal-body { |
| | | max-height: 68vh; |
| | | padding: 20rpx 30rpx; |
| | | box-sizing: border-box; |
| | | } |
| | | .modal-body .field { |
| | | background: #fff; |
| | | } |
| | | .modal-body .field:first-child { |
| | | border-radius: 16rpx 16rpx 0 0; |
| | | } |
| | | .recipient-title { |
| | | padding: 26rpx 4rpx 14rpx; |
| | | color: #20334c; |
| | | font-size: 27rpx; |
| | | font-weight: 600; |
| | | } |
| | | .recipient-list { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 14rpx; |
| | | padding: 18rpx; |
| | | background: #fff; |
| | | border-radius: 16rpx; |
| | | } |
| | | .recipient-list text { |
| | | padding: 10rpx 20rpx; |
| | | color: #53677f; |
| | | font-size: 24rpx; |
| | | background: #f3f6fb; |
| | | border: 1rpx solid transparent; |
| | | border-radius: 999rpx; |
| | | } |
| | | .recipient-list .active { |
| | | color: var(--color-primary, #1769d8); |
| | | background: #eaf2ff; |
| | | border-color: #b9d7ff; |
| | | } |
| | | .modal-actions { |
| | | display: flex; |
| | | gap: 16rpx; |
| | | padding: 18rpx 30rpx calc(18rpx + env(safe-area-inset-bottom)); |
| | | background: #fff; |
| | | } |
| | | .modal-actions button { |
| | | flex: 1; |
| | | margin: 0; |
| | | color: #52677f; |
| | | font-size: 28rpx; |
| | | background: #f3f6fb; |
| | | border-radius: 12rpx; |
| | | } |
| | | .modal-actions button:last-child { |
| | | color: #fff; |
| | | background: var(--color-primary, #1769d8); |
| | | } |
| | | </style> |
| | | <template><view class="page"><template v-if="canQuery"><view class="filters"><view class="field"><text>开始日期</text><picker mode="date" :value="filters.beginDate" @change="filters.beginDate=$event.detail.value"><view class="picker">{{ filters.beginDate||'不限' }} <text>›</text></view></picker></view><view class="field"><text>结束日期</text><picker mode="date" :value="filters.endDate" @change="filters.endDate=$event.detail.value"><view class="picker">{{ filters.endDate||'不限' }} <text>›</text></view></picker></view><view class="actions"><button @click="load(true)">查询</button><button v-if="canHandle" @click="showCheck=true">重新检测</button></view></view><scroll-view class="content" scroll-y lower-threshold="80" @scrolltolower="hasMore&&load()"><view v-if="loadError" class="state error" @click="load(true)">{{loadError}},点击重试</view><template v-else-if="rows.length"><view v-for="row in rows" :key="row.id" class="row" @click="openDetail(row)"><view class="head"><view><text class="name">{{row.alertNo||row.meterName||'能耗预警'}}</text><text>{{row.alertDate||'--'}} · {{row.alertTypeName||'--'}}</text></view><text :class="['tag',levelClass(row.alertLevel)]">{{row.alertLevelName||'普通'}}</text></view><view class="grid"><text>计量点/对象:{{row.meterName||row.objectName||'--'}}</text><text>实际/阈值:{{row.actualValue??'--'}}{{row.unit||''}} / {{row.thresholdValue??'--'}}{{row.unit||''}}</text><text>状态:{{row.statusName||statusText(row.status)}}</text><text>预警内容:{{row.content||'--'}}</text></view><view v-if="row.status===0&&canHandle" class="actions"><button @click.stop="openDetail(row)">处理</button></view></view><text class="more">{{loading?'加载中...':hasMore?'上拉加载更多':'没有更多了'}}</text></template><view v-else class="state">{{loading?'加载中...':'暂无能耗预警'}}</view></scroll-view></template><view v-else class="state">登录后查看能耗预警</view><view v-if="dialog" class="mask"><view class="dialog"><view class="title"><text>{{dialog.status===0&&canHandle?'处理能耗预警':'查看能耗预警'}}</text><text @click="dialog=undefined">×</text></view><scroll-view class="body" scroll-y><view class="detail"><text>预警编号:{{dialog.alertNo||'--'}}</text><text>计量点/对象:{{dialog.meterName||dialog.objectName||'--'}}</text><text>预警类型:{{dialog.alertTypeName||'--'}}</text><text>预警级别:{{dialog.alertLevelName||'--'}}</text><text>预警内容:{{dialog.content||'--'}}</text><text v-if="dialog.handleUserName">处理人:{{dialog.handleUserName}} {{dialog.handleTime||''}}</text></view><view v-if="dialog.status===0&&canHandle" class="remark"><text>处理说明 <text class="required">*</text></text><textarea v-model="handleRemark" placeholder="如:已安排检修、更换密封件等"/></view><view v-else-if="dialog.handleRemark" class="detail"><text>处理说明:{{dialog.handleRemark}}</text></view></scroll-view><view class="dialog-actions"><button @click="dialog=undefined">关闭</button><button v-if="dialog.status===0&&canHandle" :loading="handling" @click="handle">确认处理</button></view></view></view><view v-if="showCheck" class="mask"><view class="dialog small"><view class="title"><text>重新检测能耗预警</text><text @click="showCheck=false">×</text></view><view class="field"><text>开始日期</text><picker mode="date" :value="checkRange.beginDate" @change="checkRange.beginDate=$event.detail.value"><view class="picker">{{checkRange.beginDate}} <text>›</text></view></picker></view><view class="field"><text>结束日期</text><picker mode="date" :value="checkRange.endDate" @change="checkRange.endDate=$event.detail.value"><view class="picker">{{checkRange.endDate}} <text>›</text></view></picker></view><view class="dialog-actions"><button @click="showCheck=false">取消</button><button :loading="checking" @click="check">开始检测</button></view></view></view></view></template> |
| | | <style lang="scss" scoped>.page{height:100vh;display:flex;flex-direction:column;padding:20rpx 30rpx;box-sizing:border-box;overflow:hidden;background:#f3f6fb}.filters{margin-bottom:16rpx;overflow:hidden;background:#fff;border-radius:16rpx}.field{display:flex;align-items:center;min-height:82rpx;padding:0 22rpx;gap:18rpx;border-bottom:1rpx solid #eef1f5}.field>text:first-child{width:150rpx;color:#33455d;font-size:25rpx}.field picker,.picker{flex:1;color:#65758a;font-size:25rpx}.picker{display:flex;justify-content:space-between}.actions{display:flex;justify-content:flex-end;gap:16rpx;padding:16rpx 22rpx}.actions button,.dialog-actions button{margin:0;color:#fff;font-size:25rpx;background:var(--color-primary,#1769d8);border-radius:10rpx}.actions button:last-child,.dialog-actions button:first-child{color:#52677f;background:#f3f6fb}.content{height:0;flex:1}.row{padding:24rpx 22rpx 20rpx;margin-bottom:18rpx;background:#fff;border-radius:16rpx;box-shadow:0 10rpx 24rpx rgba(43,69,107,.08)}.head{display:flex;justify-content:space-between;gap:16rpx}.head>view{display:flex;min-width:0;flex:1;flex-direction:column;gap:8rpx}.head>view text:last-child{color:#78879b;font-size:23rpx}.name{overflow:hidden;color:#20334c;font-size:30rpx;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.tag{height:36rpx;padding:0 16rpx;font-size:22rpx;line-height:36rpx;border-radius:999rpx}.blue{color:#1769d8;background:#eef6ff}.orange{color:#b06a00;background:#fff6df}.red{color:#c65050;background:#fff7f7}.grid{display:grid;gap:10rpx;padding:16rpx 0;margin-top:16rpx;color:#66778c;font-size:23rpx;border-top:1rpx solid #edf1f6}.more,.state{display:block;padding:35rpx;color:#8795a8;font-size:26rpx;text-align:center}.error{color:#c3654d}.mask{position:fixed;inset:0;z-index:20;display:flex;align-items:flex-end;background:rgba(21,34,50,.42)}.dialog{width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;background:#f3f6fb;border-radius:24rpx 24rpx 0 0}.small{max-height:55vh}.title{display:flex;justify-content:space-between;padding:28rpx 30rpx;color:#20334c;font-size:31rpx;font-weight:700;background:#fff}.title text:last-child{font-size:42rpx;font-weight:400}.body{max-height:55vh;padding:20rpx 30rpx;box-sizing:border-box}.detail,.remark{display:flex;flex-direction:column;gap:16rpx;padding:22rpx;color:#53677f;font-size:25rpx;background:#fff;border-radius:14rpx}.remark{margin-top:16rpx}.remark textarea{width:100%;height:150rpx;margin-top:8rpx}.required{color:#e34b4b}.dialog-actions{display:flex;gap:16rpx;padding:18rpx 30rpx calc(18rpx + env(safe-area-inset-bottom));background:#fff}.dialog-actions button{flex:1}.actions button::after,.dialog-actions button::after{border:0}</style> |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import { createEnergyRecord, getEnergyRecord, updateEnergyRecord, type EnergyRecord, type EnergyType } from "../../../api/energy"; |
| | | import { createBlueEnergyRecord, getBlueEnergyLastReading, getBlueEnergyMeter, getBlueEnergyMeterList, getBlueEnergyRecord, updateBlueEnergyRecord, type BlueEnergyMeterOption, type BlueEnergyRecord } from "../../../api/energy"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | import { areaOptions, energyInfo, energyOptions, numberText, productOptions, purposeOptions, today } from "../shared"; |
| | | |
| | | const { hasPermission, state } = useAuth(); |
| | | const id = ref<number>(); |
| | | const mode = ref<"edit" | "detail">("edit"); |
| | | const saving = ref(false); |
| | | const form = reactive<EnergyRecord>(initial()); |
| | | const readonly = computed(() => mode.value === "detail"); |
| | | const canSave = computed(() => !readonly.value && (id.value ? hasPermission("energy:record:update") : hasPermission("energy:record:create"))); |
| | | const estimatedAmount = computed(() => Number(form.quantity || 0) * Number(form.unitPrice || 0)); |
| | | const unit = computed(() => energyInfo[form.energyType]?.unit || ""); |
| | | |
| | | function initial(): EnergyRecord { |
| | | return { recordDate: today(), energyType: "WATER", usageArea: "", productCategory: "燕窝", usagePurpose: "", quantity: 1, unitPrice: 0, recorder: "", remark: "" }; |
| | | } |
| | | function reset(data: EnergyRecord) { |
| | | Object.keys(form).forEach(key => delete (form as unknown as Record<string, unknown>)[key]); |
| | | Object.assign(form, data); |
| | | } |
| | | function pickEnergy(index: number) { |
| | | form.energyType = energyOptions[index].value; |
| | | } |
| | | function pickProduct(index: number) { |
| | | form.productCategory = productOptions[index]; |
| | | } |
| | | function pickArea(index: number) { |
| | | form.usageArea = areaOptions[index]; |
| | | } |
| | | function pickPurpose(index: number) { |
| | | form.usagePurpose = purposeOptions[index]; |
| | | } |
| | | function num(value: string, fallback = 0) { |
| | | if (value === "") return fallback; |
| | | const result = Number(value); |
| | | return Number.isFinite(result) ? result : fallback; |
| | | } |
| | | function validate() { |
| | | if (!form.recordDate) return "请选择统计日期"; |
| | | if (!form.energyType) return "请选择能源类型"; |
| | | if (!form.usageArea?.trim()) return "请输入使用区域"; |
| | | if (!form.productCategory) return "请选择产品类别"; |
| | | if (!form.usagePurpose?.trim()) return "请输入用途"; |
| | | if (!(Number(form.quantity) > 0)) return "用量必须大于零"; |
| | | if (!(Number(form.unitPrice) >= 0)) return "单价不能为负数"; |
| | | if (!form.recorder?.trim()) return "请输入录入人"; |
| | | return ""; |
| | | } |
| | | async function save() { |
| | | if (!state.accessToken || saving.value) return; |
| | | const message = validate(); |
| | | if (message) return uni.showToast({ title: message, icon: "none" }); |
| | | saving.value = true; |
| | | try { |
| | | const payload: EnergyRecord = { |
| | | ...form, |
| | | usageArea: form.usageArea.trim(), |
| | | usagePurpose: form.usagePurpose.trim(), |
| | | recorder: form.recorder.trim(), |
| | | quantity: Number(form.quantity), |
| | | unitPrice: Number(form.unitPrice), |
| | | }; |
| | | if (id.value) await updateEnergyRecord(state.accessToken, payload); |
| | | else await createEnergyRecord(state.accessToken, payload); |
| | | uni.showToast({ title: "保存成功", icon: "success" }); |
| | | setTimeout(() => uni.navigateBack(), 500); |
| | | } catch (error) { |
| | | uni.showToast({ title: error instanceof Error ? error.message : "保存失败", icon: "none" }); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | } |
| | | async function loadDetail(recordId: number) { |
| | | if (!state.accessToken) return; |
| | | try { |
| | | const data = await getEnergyRecord(state.accessToken, recordId); |
| | | reset(data); |
| | | } catch (error) { |
| | | uni.showToast({ title: error instanceof Error ? error.message : "详情加载失败", icon: "none" }); |
| | | } |
| | | } |
| | | |
| | | onLoad((query) => { |
| | | id.value = query?.id ? Number(query.id) : undefined; |
| | | mode.value = query?.mode === "detail" ? "detail" : "edit"; |
| | | if (id.value) loadDetail(id.value); |
| | | }); |
| | | const { hasPermission, state } = useAuth(); const id = ref<number>(), mode = ref<"edit"|"detail">("edit"), saving = ref(false), meters = ref<BlueEnergyMeterOption[]>([]), loading = ref(false); |
| | | const today = () => new Date().toISOString().slice(0,10); |
| | | const form = reactive<BlueEnergyRecord>({ recordDate: today(), prevReading: 0, reading: 0, price: 0, unit: "", remark: "" }); |
| | | const readonly = computed(() => mode.value === "detail"), canSave = computed(() => !readonly.value && (id.value ? hasPermission("mes:em-record:update") : hasPermission("mes:em-record:create"))); |
| | | const meterText = computed(() => meters.value.find(item => item.id === form.meterId)?.name || form.meterName || "请选择计量点"); |
| | | const usage = computed(() => Math.max(0, Number(form.reading || 0) - Number(form.prevReading || 0)) * Number(form.ratio || 1)); const cost = computed(() => usage.value * Number(form.price || 0)); |
| | | function numberValue(value: string) { const number = Number(value); return Number.isFinite(number) ? number : 0; } |
| | | function reset(data: BlueEnergyRecord) { Object.keys(form).forEach(key => delete (form as Record<string, unknown>)[key]); Object.assign(form, data); } |
| | | async function loadMeters() { if (!state.accessToken) return; meters.value = await getBlueEnergyMeterList(state.accessToken); } |
| | | async function syncMeter() { if (!state.accessToken || !form.meterId) return; const meter = await getBlueEnergyMeter(state.accessToken, form.meterId); Object.assign(form, { meterName: meter.name, meterCode: meter.code, energyType: meter.energyType, energyTypeName: meter.energyTypeName, unit: meter.unit || "", workshopName: meter.workshopName, lineName: meter.lineName, ratio: meter.ratio || 1 }); await syncPrevious(); } |
| | | async function syncPrevious() { if (!state.accessToken || !form.meterId || !form.recordDate) return; const data = await getBlueEnergyLastReading(state.accessToken, form.meterId, form.recordDate, id.value); form.prevReading = data.prevReading || 0; } |
| | | async function pickMeter(index: number) { form.meterId = meters.value[index].id; await syncMeter(); } |
| | | async function save() { if (!state.accessToken || saving.value) return; if (!form.meterId) return uni.showToast({ title: "请选择计量点", icon: "none" }); if (!form.recordDate) return uni.showToast({ title: "请选择抄表日期", icon: "none" }); if (Number(form.reading) < Number(form.prevReading)) return uni.showToast({ title: "本期读数不能小于上期读数", icon: "none" }); saving.value = true; try { const payload: BlueEnergyRecord = { id: id.value, meterId: form.meterId, recordDate: form.recordDate, reading: Number(form.reading || 0), price: Number(form.price || 0), remark: form.remark || "" }; if (id.value) await updateBlueEnergyRecord(state.accessToken, payload); else await createBlueEnergyRecord(state.accessToken, payload); uni.showToast({ title: "保存成功", icon: "success" }); setTimeout(() => uni.navigateBack(), 500); } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : "保存失败", icon: "none" }); } finally { saving.value = false; } } |
| | | async function init(recordId?: number) { loading.value = true; try { await loadMeters(); if (recordId && state.accessToken) reset(await getBlueEnergyRecord(state.accessToken, recordId)); } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : "数据加载失败", icon: "none" }); } finally { loading.value = false; } } |
| | | onLoad(query => { id.value = query.id ? Number(query.id) : undefined; mode.value = query.mode === "detail" ? "detail" : "edit"; init(id.value); }); |
| | | </script> |
| | | |
| | | <template> |
| | | <view class="page"> |
| | | <view class="form-card"> |
| | | <view class="section">能耗信息</view> |
| | | <view v-if="id" class="field"><text>记录编号</text><text class="value">{{ id }}</text></view> |
| | | <view class="field"><text>统计日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="form.recordDate" :end="today()" @change="form.recordDate=$event.detail.value"><view class="picker"><text>{{ form.recordDate || '请选择统计日期' }}</text><text>›</text></view></picker><text v-else class="value">{{ form.recordDate || '--' }}</text></view> |
| | | <view class="field"><text>能源类型 <text class="required">*</text></text><picker v-if="!readonly" :range="energyOptions" range-key="label" @change="pickEnergy(Number($event.detail.value))"><view class="picker"><text>{{ energyInfo[form.energyType]?.name || '请选择能源类型' }}</text><text>›</text></view></picker><text v-else class="value">{{ energyInfo[form.energyType]?.name || '--' }}</text></view> |
| | | <view class="field"><text>使用区域 <text class="required">*</text></text><picker v-if="!readonly" :range="areaOptions" @change="pickArea(Number($event.detail.value))"><view class="picker"><text>{{ form.usageArea || '选择建议区域' }}</text><text>›</text></view></picker><text v-else class="value">{{ form.usageArea || '--' }}</text></view> |
| | | <view v-if="!readonly" class="field"><text>区域手输</text><input v-model="form.usageArea" placeholder="也可手动输入区域" /></view> |
| | | <view class="field"><text>产品类别 <text class="required">*</text></text><picker v-if="!readonly" :range="productOptions" @change="pickProduct(Number($event.detail.value))"><view class="picker"><text>{{ form.productCategory || '请选择产品类别' }}</text><text>›</text></view></picker><text v-else class="value">{{ form.productCategory || '--' }}</text></view> |
| | | <view class="field"><text>用途 / 工序 <text class="required">*</text></text><picker v-if="!readonly" :range="purposeOptions" @change="pickPurpose(Number($event.detail.value))"><view class="picker"><text>{{ form.usagePurpose || '选择建议用途' }}</text><text>›</text></view></picker><text v-else class="value">{{ form.usagePurpose || '--' }}</text></view> |
| | | <view v-if="!readonly" class="field"><text>用途手输</text><input v-model="form.usagePurpose" placeholder="也可手动输入用途" /></view> |
| | | <view class="field"><text>录入人 <text class="required">*</text></text><input v-if="!readonly" v-model="form.recorder" placeholder="请输入姓名" maxlength="50" /><text v-else class="value">{{ form.recorder || '--' }}</text></view> |
| | | <view class="field"><text>用量({{ unit }}) <text class="required">*</text></text><input v-if="!readonly" :value="String(form.quantity ?? '')" type="digit" placeholder="请输入用量" @input="form.quantity=num($event.detail.value)" /><text v-else class="value">{{ numberText(form.quantity) }} {{ unit }}</text></view> |
| | | <view class="field"><text>单价(元/{{ unit }}) <text class="required">*</text></text><input v-if="!readonly" :value="String(form.unitPrice ?? '')" type="digit" placeholder="请输入单价" @input="form.unitPrice=num($event.detail.value)" /><text v-else class="value">{{ numberText(form.unitPrice, 4) }}</text></view> |
| | | <view class="field"><text>费用(元)</text><text class="value strong">{{ numberText(readonly ? form.amount : estimatedAmount, 2) }}</text></view> |
| | | <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" maxlength="500" placeholder="如计量口径、特殊用能情况等" /></view> |
| | | </view> |
| | | <button v-if="canSave" class="save" :loading="saving" :disabled="saving" @click="save">保存</button> |
| | | <button v-else-if="readonly && hasPermission('energy:record:update')" class="save" @click="mode='edit'">编辑</button> |
| | | <view v-else class="state">暂无保存权限</view> |
| | | </view> |
| | | </template> |
| | | |
| | | <style lang="scss" scoped> |
| | | .page{min-height:100vh;padding:24rpx 30rpx 70rpx;box-sizing:border-box;background:#f6f7f9}.form-card{overflow:hidden;background:#fff;border-radius:16rpx}.section{padding:22rpx 24rpx 12rpx;color:var(--color-primary, #1769d8);font-size:25rpx;font-weight:600;background:#f8fbff}.field{display:flex;align-items:center;min-height:96rpx;padding:0 24rpx;gap:20rpx;border-bottom:1rpx solid #eef1f5}.field>text:first-child{width:220rpx;flex:none;color:#33455d;font-size:27rpx}.required{color:#e34b4b}.field input,.field picker,.picker,.value{min-width:0;flex:1;color:#1e3049;font-size:27rpx}.field input,.value{text-align:right}.picker{display:flex;width:100%;align-items:center;justify-content:space-between;gap:12rpx;color:#65758a}.picker text:first-child,.value{min-width:0;flex:1;overflow:hidden;text-align:right;text-overflow:ellipsis;white-space:nowrap}.picker text:last-child{flex:none;width:24rpx;color:#8092a7;font-size:36rpx;text-align:right}.strong{color:var(--color-primary, #1769d8);font-weight:700}.column{align-items:flex-start;flex-direction:column;padding:20rpx 24rpx}.column>text:first-child{width:auto}.column textarea{width:100%;height:150rpx;box-sizing:border-box;font-size:27rpx}.save{margin-top:32rpx;color:#fff;font-size:30rpx;background:var(--color-primary, #1769d8);border-radius:12rpx}.save::after{border:0}.state{padding:38rpx;color:#8795a8;font-size:26rpx;text-align:center} |
| | | </style> |
| | | <template><view class="page"><view class="form-card"><view class="section">能耗抄表信息</view><view class="field"><text>计量点 <text class="required">*</text></text><picker v-if="!readonly" :range="meters" range-key="name" @change="pickMeter(Number($event.detail.value))"><view class="picker"><text>{{ meterText }}</text><text>›</text></view></picker><text v-else class="value">{{ meterText }}</text></view><view class="field"><text>抄表日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="form.recordDate" @change="form.recordDate=$event.detail.value;syncPrevious()"><view class="picker"><text>{{ form.recordDate || '请选择日期' }}</text><text>›</text></view></picker><text v-else class="value">{{ form.recordDate || '--' }}</text></view><view class="field"><text>能源类型</text><text class="value">{{ form.energyTypeName || '--' }}</text></view><view class="field"><text>车间 / 产线</text><text class="value">{{ form.workshopName || '--' }} / {{ form.lineName || '--' }}</text></view><view class="field"><text>上期读数</text><text class="value">{{ Number(form.prevReading || 0).toFixed(4) }} {{ form.unit || '' }}</text></view><view class="field"><text>本期读数 <text class="required">*</text></text><input v-if="!readonly" :value="String(form.reading ?? '')" type="digit" @input="form.reading=numberValue($event.detail.value)" placeholder="请输入读数"/><text v-else class="value">{{ Number(form.reading || 0).toFixed(4) }} {{ form.unit || '' }}</text></view><view class="field"><text>单价(元)</text><input v-if="!readonly" :value="String(form.price ?? '')" type="digit" @input="form.price=numberValue($event.detail.value)" placeholder="请输入单价"/><text v-else class="value">{{ Number(form.price || 0).toFixed(4) }}</text></view><view class="field"><text>本期用量</text><text class="value strong">{{ usage.toFixed(4) }} {{ form.unit || '' }}</text></view><view class="field"><text>费用(元)</text><text class="value strong">{{ cost.toFixed(2) }}</text></view><view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注"/></view></view><button v-if="canSave" class="save" :loading="saving||loading" :disabled="saving||loading" @click="save">保存</button><button v-else-if="readonly&&hasPermission('mes:em-record:update')" class="save" @click="mode='edit'">编辑</button></view></template> |
| | | <style lang="scss" scoped>.page{min-height:100vh;padding:24rpx 30rpx 70rpx;background:#f6f7f9}.form-card{overflow:hidden;background:#fff;border-radius:16rpx}.section{padding:22rpx 24rpx 12rpx;color:var(--color-primary,#1769d8);font-size:25rpx;font-weight:600;background:#f8fbff}.field{display:flex;align-items:center;min-height:96rpx;padding:0 24rpx;gap:20rpx;border-bottom:1rpx solid #eef1f5}.field>text:first-child{width:220rpx;flex:none;color:#33455d;font-size:27rpx}.required{color:#e34b4b}.field input,.field picker,.picker,.value{min-width:0;flex:1;color:#1e3049;font-size:27rpx}.field input,.value{text-align:right}.picker{display:flex;justify-content:space-between;color:#65758a}.picker text:first-child,.value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.picker text:last-child{font-size:36rpx}.strong{color:var(--color-primary,#1769d8);font-weight:700}.column{align-items:flex-start;flex-direction:column;padding:20rpx 24rpx}.column>text:first-child{width:auto}.column textarea{width:100%;height:150rpx}.save{margin-top:32rpx;color:#fff;font-size:30rpx;background:var(--color-primary,#1769d8);border-radius:12rpx}.save::after{border:0}</style> |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from "vue"; |
| | | import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; |
| | | import { deleteEnergyRecord, getEnergyRecordPage, type EnergyRecord, type EnergyType } from "../../../api/energy"; |
| | | import { deleteBlueEnergyRecord, getBlueEnergyRecordPage, type BlueEnergyRecord } from "../../../api/energy"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | import { energyInfo, energyName, numberText } from "../shared"; |
| | | |
| | | const { hasPermission, isLoggedIn, state } = useAuth(); |
| | | const keyword = ref(""); |
| | | const rows = ref<EnergyRecord[]>([]); |
| | | const pageNo = ref(1); |
| | | const total = ref(0); |
| | | const loading = ref(false); |
| | | const loadError = ref(""); |
| | | const canQuery = computed(() => isLoggedIn.value); |
| | | const canDetail = computed(() => hasPermission("energy:record:query")); |
| | | const canCreate = computed(() => hasPermission("energy:record:create")); |
| | | const canUpdate = computed(() => hasPermission("energy:record:update")); |
| | | const canDelete = computed(() => hasPermission("energy:record:delete")); |
| | | const rows = ref<BlueEnergyRecord[]>([]), pageNo = ref(1), total = ref(0), loading = ref(false), loadError = ref(""); |
| | | const canQuery = computed(() => isLoggedIn.value), canCreate = computed(() => hasPermission("mes:em-record:create")), canUpdate = computed(() => hasPermission("mes:em-record:update")), canDelete = computed(() => hasPermission("mes:em-record:delete")); |
| | | const hasMore = computed(() => rows.value.length < total.value); |
| | | |
| | | async function load(reset = false) { |
| | | if (!state.accessToken || loading.value || !canQuery.value) return; |
| | | if (reset) { pageNo.value = 1; rows.value = []; } |
| | | loading.value = true; loadError.value = ""; |
| | | try { |
| | | const search = keyword.value.trim(); |
| | | const data = await getEnergyRecordPage(state.accessToken, { |
| | | pageNo: pageNo.value, |
| | | pageSize: 15, |
| | | usageArea: search || undefined, |
| | | }); |
| | | total.value = data.total || 0; |
| | | rows.value = reset ? (data.list || []) : rows.value.concat(data.list || []); |
| | | pageNo.value += 1; |
| | | } catch (error) { |
| | | loadError.value = error instanceof Error ? error.message : "能耗记录加载失败"; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | function go(id?: number, mode: "edit" | "detail" = "edit") { |
| | | uni.navigateTo({ url: `/pages/energy/record/form${id ? `?id=${id}&mode=${mode}` : ""}` }); |
| | | } |
| | | async function remove(row: EnergyRecord) { |
| | | if (!state.accessToken || !row.id) return; |
| | | const ok = await new Promise<boolean>(resolve => uni.showModal({ title: "确认删除", content: "确定删除这条能耗记录?删除后统计数据也会更新。", success: res => resolve(res.confirm) })); |
| | | if (!ok) return; |
| | | try { |
| | | await deleteEnergyRecord(state.accessToken, row.id); |
| | | uni.showToast({ title: "删除成功", icon: "success" }); |
| | | await load(true); |
| | | } catch (error) { |
| | | uni.showToast({ title: error instanceof Error ? error.message : "删除失败", icon: "none" }); |
| | | } |
| | | } |
| | | function unit(type?: EnergyType) { |
| | | return type ? energyInfo[type]?.unit || "" : ""; |
| | | } |
| | | |
| | | onShow(() => load(true)); |
| | | onPullDownRefresh(async () => { await load(true); uni.stopPullDownRefresh(); }); |
| | | const numberText = (value?: number, digits = 2) => Number(value || 0).toFixed(digits); |
| | | async function load(reset = false) { if (!state.accessToken || loading.value) return; if (reset) { pageNo.value = 1; rows.value = []; } loading.value = true; loadError.value = ""; try { const data = await getBlueEnergyRecordPage(state.accessToken, { pageNo: pageNo.value, pageSize: 15 }); total.value = data.total || 0; rows.value = reset ? data.list || [] : rows.value.concat(data.list || []); pageNo.value += 1; } catch (error) { loadError.value = error instanceof Error ? error.message : "能耗记录加载失败"; } finally { loading.value = false; } } |
| | | function go(id?: number, mode: "edit" | "detail" = "edit") { uni.navigateTo({ url: `/pages/energy/record/form${id ? `?id=${id}&mode=${mode}` : ""}` }); } |
| | | async function remove(row: BlueEnergyRecord) { if (!state.accessToken || !row.id) return; const ok = await new Promise<boolean>(resolve => uni.showModal({ title: "确认删除", content: `确定删除 ${row.meterName || "该计量点"} 的抄表记录?`, success: res => resolve(res.confirm) })); if (!ok) return; try { await deleteBlueEnergyRecord(state.accessToken, row.id); uni.showToast({ title: "删除成功", icon: "success" }); load(true); } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : "删除失败", icon: "none" }); } } |
| | | onShow(() => load(true)); onPullDownRefresh(async () => { await load(true); uni.stopPullDownRefresh(); }); |
| | | </script> |
| | | |
| | | <template> |
| | | <view class="page"> |
| | | <template v-if="isLoggedIn"> |
| | | <view v-if="canQuery" class="search"> |
| | | <input v-model="keyword" placeholder="搜索使用区域" confirm-type="search" @confirm="load(true)" /> |
| | | <text v-if="keyword" @click="keyword='';load(true)">清除</text> |
| | | </view> |
| | | <scroll-view v-if="canQuery" class="content" scroll-y lower-threshold="80" @scrolltolower="hasMore&&load()"> |
| | | <view v-if="loadError" class="state error" @click="load(true)">{{ loadError }},点击重试</view> |
| | | <template v-else-if="rows.length"> |
| | | <view v-for="row in rows" :key="row.id" class="row" @click="go(row.id,'detail')"> |
| | | <view class="head"> |
| | | <view><text class="name">{{ row.usageArea || '未填写区域' }}</text><text>{{ row.recordDate || '--' }} · {{ row.productCategory || '--' }}</text></view> |
| | | <view class="status-wrap"><text :class="['status', energyInfo[row.energyType]?.tone || 'tag-blue']">{{ energyName(row.energyType) }}</text></view> |
| | | </view> |
| | | <view class="grid"> |
| | | <text>用途:{{ row.usagePurpose || '--' }}</text> |
| | | <text>用量:{{ numberText(row.quantity) }} {{ unit(row.energyType) }}</text> |
| | | <text>单价:{{ numberText(row.unitPrice, 4) }} 元/{{ unit(row.energyType) }}</text> |
| | | <text>费用:{{ numberText(row.amount ?? Number(row.quantity || 0) * Number(row.unitPrice || 0), 2) }} 元</text> |
| | | <text>录入人:{{ row.recorder || '--' }}</text> |
| | | <text>创建时间:{{ row.createTime || '--' }}</text> |
| | | </view> |
| | | <view class="actions" @click.stop> |
| | | <button v-if="canDetail" @click="go(row.id,'detail')">详情</button> |
| | | <button v-if="canUpdate" @click="go(row.id)">编辑</button> |
| | | <button v-if="canDelete" class="danger" @click="remove(row)">删除</button> |
| | | </view> |
| | | </view> |
| | | <text class="more">{{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '没有更多了' }}</text> |
| | | </template> |
| | | <view v-else class="state">{{ loading ? '加载中...' : keyword ? '没有匹配的能耗记录' : '暂无能耗记录' }}</view> |
| | | </scroll-view> |
| | | <view v-else class="state">暂无能耗记录查看权限</view> |
| | | </template> |
| | | <view v-else class="state">登录后查看能耗记录</view> |
| | | <button v-if="canCreate" class="fab" @click="go()"><view /></button> |
| | | </view> |
| | | </template> |
| | | |
| | | <style lang="scss" scoped> |
| | | .page{height:100vh;display:flex;flex-direction:column;padding:0 30rpx;box-sizing:border-box;overflow:hidden;background:#f3f6fb}.search{display:flex;align-items:center;height:80rpx;margin:20rpx 0 16rpx;padding:0 20rpx;background:#fff;border-radius:16rpx;box-shadow:0 8rpx 20rpx rgba(43,69,107,.06)}.search input{min-width:0;flex:1;color:#1e3049;font-size:27rpx}.search text{flex:none;color:var(--color-primary, #1769d8);font-size:24rpx}.content{height:0;flex:1;padding-bottom: 132rpx;box-sizing:border-box}.row{padding:24rpx 22rpx 20rpx;margin-bottom:18rpx;background:#fff;border-radius:16rpx;box-shadow:0 10rpx 24rpx rgba(43,69,107,.08)}.head{display:flex;align-items:flex-start;justify-content:space-between;gap:16rpx}.head>view:first-child{display:flex;min-width:0;flex:1;flex-direction:column;gap:8rpx}.head>view:first-child text:last-child{overflow:hidden;color:#78879b;font-size:23rpx;text-overflow:ellipsis;white-space:nowrap}.name{overflow:hidden;color:#20334c;font-size:30rpx;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.status-wrap{display:flex;align-items:center;flex:none;height:48rpx}.status{display:inline-flex;align-items:center;height:36rpx;padding:0 16rpx;font-size:22rpx;border-radius:999rpx;white-space:nowrap}.grid{display:grid;grid-template-columns:1fr 1fr;gap:10rpx 18rpx;padding:16rpx 0;margin-top:16rpx;color:#66778c;font-size:23rpx;border-top:1rpx solid #edf1f6;border-bottom:1rpx solid #edf1f6}.grid text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:12rpx;padding-top:16rpx}.actions button{min-height:50rpx;padding:0 16rpx;margin:0;color:var(--color-primary, #1769d8);font-size:22rpx;line-height:48rpx;background:var(--color-primary-soft, #f5f8ff);border:1rpx solid var(--color-primary-border, #8ab3ee);border-radius:8rpx}.actions .danger{color:#c65050;background:#fff7f7;border-color:#e3a0a0}.actions button::after,.fab::after{border:0}.more,.state{display:block;padding:35rpx;color:#8795a8;font-size:26rpx;text-align:center}.error{color:#c3654d}.tag-blue{color:var(--color-primary, #1769d8);background:var(--color-primary-soft, #eef6ff);border:1rpx solid var(--color-primary-border, #c9e1ff)}.tag-orange{color:#b06a00;background:#fff6df;border:1rpx solid #f0d390}.tag-green{color:#0e9663;background:#e9fbf2;border:1rpx solid #b8eed4}.fab{position:fixed;right:30rpx;bottom:calc(32rpx + env(safe-area-inset-bottom));display:flex;align-items:center;justify-content:center;width: 104rpx; height: 104rpx; min-height: 104rpx;padding:0;margin:0;background:var(--color-primary, #1769d8);border-radius:50%;box-shadow:0 12rpx 24rpx rgba(23,105,216,.3)}.fab view{width:38rpx;height:38rpx;background:linear-gradient(#fff,#fff) center/38rpx 8rpx no-repeat,linear-gradient(#fff,#fff) center/8rpx 38rpx no-repeat}.fab view::before,.fab view::after{display:none;content:none} |
| | | </style> |
| | | <template><view class="page"><template v-if="canQuery"><scroll-view class="content" scroll-y lower-threshold="80" @scrolltolower="hasMore&&load()"><view v-if="loadError" class="state error" @click="load(true)">{{ loadError }},点击重试</view><template v-else-if="rows.length"><view v-for="row in rows" :key="row.id" class="row" @click="go(row.id,'detail')"><view class="head"><view><text class="name">{{ row.meterName || '未命名计量点' }}</text><text>{{ row.recordDate || '--' }} · {{ row.energyTypeName || '--' }}</text></view><text class="tag">{{ row.unit || '--' }}</text></view><view class="grid"><text>上期读数:{{ numberText(row.prevReading,4) }}</text><text>本期读数:{{ numberText(row.reading,4) }}</text><text>用量:{{ numberText(row.usageAmount,4) }} {{ row.unit || '' }}</text><text>费用:¥{{ numberText(row.cost) }}</text><text>车间/产线:{{ row.workshopName || '--' }} / {{ row.lineName || '--' }}</text><text>录入时间:{{ row.createTime || '--' }}</text></view><view class="actions" @click.stop><button @click="go(row.id,'detail')">详情</button><button v-if="canUpdate" @click="go(row.id)">编辑</button><button v-if="canDelete" class="danger" @click="remove(row)">删除</button></view></view><text class="more">{{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '没有更多了' }}</text></template><view v-else class="state">{{ loading ? '加载中...' : '暂无能耗抄表记录' }}</view></scroll-view></template><view v-else class="state">登录后查看能耗记录</view><button v-if="canCreate" class="fab" @click="go()"><view /></button></view></template> |
| | | <style lang="scss" scoped>.page{height:100vh;display:flex;flex-direction:column;padding:20rpx 30rpx;box-sizing:border-box;overflow:hidden;background:#f3f6fb}.content{height:0;flex:1;padding-bottom:132rpx}.row{padding:24rpx 22rpx 20rpx;margin-bottom:18rpx;background:#fff;border-radius:16rpx;box-shadow:0 10rpx 24rpx rgba(43,69,107,.08)}.head{display:flex;justify-content:space-between;gap:16rpx}.head>view{display:flex;min-width:0;flex:1;flex-direction:column;gap:8rpx}.head>view text:last-child{color:#78879b;font-size:23rpx}.name{overflow:hidden;color:#20334c;font-size:30rpx;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.tag{height:36rpx;padding:0 16rpx;color:var(--color-primary,#1769d8);font-size:22rpx;line-height:36rpx;background:#eef6ff;border-radius:999rpx}.grid{display:grid;grid-template-columns:1fr 1fr;gap:10rpx 18rpx;padding:16rpx 0;margin-top:16rpx;color:#66778c;font-size:23rpx;border-top:1rpx solid #edf1f6;border-bottom:1rpx solid #edf1f6}.grid text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;justify-content:flex-end;gap:12rpx;padding-top:16rpx}.actions button{min-height:50rpx;padding:0 16rpx;margin:0;color:var(--color-primary,#1769d8);font-size:22rpx;line-height:48rpx;background:#f5f8ff;border:1rpx solid #8ab3ee;border-radius:8rpx}.actions .danger{color:#c65050;background:#fff7f7;border-color:#e3a0a0}.actions button::after,.fab::after{border:0}.more,.state{display:block;padding:35rpx;color:#8795a8;font-size:26rpx;text-align:center}.error{color:#c3654d}.fab{position:fixed;right:30rpx;bottom:calc(32rpx + env(safe-area-inset-bottom));width:104rpx;height:104rpx;padding:0;margin:0;background:var(--color-primary,#1769d8);border-radius:50%;box-shadow:0 12rpx 24rpx rgba(23,105,216,.3)}.fab view{width:38rpx;height:38rpx;margin:auto;background:linear-gradient(#fff,#fff) center/38rpx 8rpx no-repeat,linear-gradient(#fff,#fff) center/8rpx 38rpx no-repeat}</style> |
| | |
| | | productUnitName: productUnitName(material), |
| | | productPrice: productPrice(material), |
| | | count: item.count || 1, |
| | | needProduction: item.needProduction ?? 1, |
| | | needProduction: item.needProduction ?? 0, |
| | | }); |
| | | recalc(); |
| | | } |
| | |
| | | count: 1, |
| | | productPrice: 0, |
| | | taxPercent: 0, |
| | | needProduction: 1, |
| | | needProduction: 0, |
| | | totalProductPrice: 0, |
| | | taxPrice: 0, |
| | | totalPrice: 0, |
| | |
| | | productPrice: numberValue(item.productPrice), |
| | | count: numberValue(item.count), |
| | | taxPercent: numberValue(item.taxPercent), |
| | | needProduction: item.needProduction ?? 1, |
| | | needProduction: item.needProduction ?? 0, |
| | | remark: item.remark, |
| | | })), |
| | | }; |
| | |
| | | @change="selectProduct($event, index)"><view class="picker">{{ lineProductText(item) }}<text>›</text></view></picker></view> |
| | | <view class="field"><text>规格型号</text><view>{{ item.productSpecification || '—' }}</view></view> |
| | | <view class="field"><text>单位</text><view>{{ item.productUnitName || '—' }}</view></view> |
| | | <view class="field"><text>需要生产</text><radio-group @change="item.needProduction = Number($event.detail.value); recalc()"><label><radio value="1" |
| | | :checked="item.needProduction !== 0" />是</label><label><radio value="0" |
| | | :checked="item.needProduction === 0" />否</label></radio-group></view> |
| | | <view class="field"><text>需要生产</text><text class="value">否</text></view> |
| | | <view class="field"><text>数量 <text class="required">*</text></text><input v-model.number="item.count" |
| | | type="digit" |
| | | placeholder="数量" |