zhangwencui
7 天以前 ec1dded308c62a90bfc6d9efdc34304d873342f4
feat(app): 对齐蓝羽纸业已有功能
已修改6个文件
1326 ■■■■■ 文件已修改
src/api/energy.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/business/form.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/energy/alert/index.vue 1048 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/energy/record/form.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/energy/record/index.vue 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sale/order/form.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/energy.ts
@@ -117,3 +117,20 @@
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) }); }
src/pages/crm/business/form.vue
@@ -58,12 +58,8 @@
  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);
src/pages/energy/alert/index.vue
@@ -1,1037 +1,15 @@
<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>
src/pages/energy/record/form.vue
@@ -1,120 +1,23 @@
<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>
src/pages/energy/record/index.vue
@@ -1,107 +1,17 @@
<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>
src/pages/sale/order/form.vue
@@ -146,7 +146,7 @@
      productUnitName: productUnitName(material),
      productPrice: productPrice(material),
      count: item.count || 1,
      needProduction: item.needProduction ?? 1,
      needProduction: item.needProduction ?? 0,
    });
    recalc();
  }
@@ -155,7 +155,7 @@
      count: 1,
      productPrice: 0,
      taxPercent: 0,
      needProduction: 1,
      needProduction: 0,
      totalProductPrice: 0,
      taxPrice: 0,
      totalPrice: 0,
@@ -197,7 +197,7 @@
          productPrice: numberValue(item.productPrice),
          count: numberValue(item.count),
          taxPercent: numberValue(item.taxPercent),
          needProduction: item.needProduction ?? 1,
          needProduction: item.needProduction ?? 0,
          remark: item.remark,
        })),
      };
@@ -270,9 +270,7 @@
                  @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="数量"