gaoluyang
14 小时以前 e449a5408265e4bd1f6c66f5be28a42efac444ee
天津豹鸣app
1.需求修改
已添加77个文件
已修改23个文件
18235 ■■■■■ 文件已修改
src/api/collaborativeApproval/broadbandCommunication.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/collaborativeApproval/houseLeaseManagement.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/inventoryManagement/vehicle.js 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/config.js 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/project.js 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/projectType.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/role.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/qualityManagement/inspectionReport.js 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/qualityManagement/qualityInspectFile.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/salesManagement/biddingManagement.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/salesManagement/contractManagement.js 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/salesManagement/deliveryLedger.js 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/salesManagement/indicatorStats.js 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/salesManagement/lawsuitManagement.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/FilterPanel.vue 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/FormPicker.vue 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/basicData/customerFile/edit.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/basicData/customerFile/index.vue 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/broadbandCommunication/detail.vue 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/broadbandCommunication/edit.vue 316 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/broadbandCommunication/index.vue 262 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/houseLeaseManagement/detail.vue 406 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/houseLeaseManagement/edit.vue 603 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/houseLeaseManagement/index.vue 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/deviceInfo/index.vue 198 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/ledger/detail.vue 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/ledger/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/fuelCard/detail.vue 273 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/fuelCard/edit.vue 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/fuelCard/index.vue 282 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/fuelCard/recharge.vue 401 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/fuelRecord/detail.vue 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/fuelRecord/edit.vue 295 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/fuelRecord/index.vue 377 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/vehicleInfo/detail.vue 510 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/vehicleInfo/edit.vue 249 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/vehicleInfo/index.vue 331 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-form/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/finReimbursementMappers.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/_components/AddressRow.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/_components/ProductRow.vue 363 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/_components/TeamRow.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/_utils/dict.js 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/_utils/mapper.js 318 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/_utils/stage.js 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/archive.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/config.vue 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/detail.vue 948 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/edit.vue 574 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/index.vue 357 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/projectManagement/project/progress.vue 326 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/finalInspection/_utils/inspection.js 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/finalInspection/add.vue 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/finalInspection/detail.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/finalInspection/fileList.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/inspectionReport/detail.vue 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/inspectionReport/edit.vue 290 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/inspectionReport/index.vue 265 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/biddingManagement/detail.vue 246 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/biddingManagement/edit.vue 337 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/biddingManagement/index.vue 305 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/contractManagement/detail.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/contractManagement/edit.vue 628 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/contractManagement/index.vue 279 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/deliveryLedger/detail.vue 405 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/deliveryLedger/edit.vue 385 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/deliveryLedger/index.vue 336 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/lawsuitManagement/detail.vue 262 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/lawsuitManagement/edit.vue 344 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/lawsuitManagement/index.vue 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/receiptPaymentLedger/detail.vue 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/receiptPaymentLedger/index.vue 278 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesAccount/detail.vue 407 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesAccount/goOut.vue 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesAccount/index.vue 29 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesAccount/view.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesQuotation/detail.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesQuotation/edit.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesQuotation/index.vue 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/works.vue 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/baobiaoguanli.svg 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/cheliangguanli.svg 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/fangwuzulin.svg 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/jiayoujilu.svg 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/jiayoukaguanli.svg 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/kuandaiyutongxin.svg 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/susongguanli.svg 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/xiangmuguanli.svg 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/zhaotoubiaoguanli.svg 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/delivery.js 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/detailCache.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/file.js 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/list.js 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/paymentCycle.js 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/prefill.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/projectOptions.js 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/warning.js 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/collaborativeApproval/broadbandCommunication.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢ï¼ˆwarnStatus: normal æ­£å¸¸ / expiring å³å°†åˆ°æœŸ / overdue å·²é€¾æœŸï¼‰
export function broadbandListPage(query) {
  return request({
    url: "/broadband/listPage",
    method: "get",
    params: query,
  });
}
export function getBroadbandDetail(id) {
  return request({
    url: "/broadband/getDetail",
    method: "get",
    params: { id },
  });
}
// æ–°å¢žä¸Žä¿®æ”¹å…±ç”¨ï¼Œé  id æ˜¯å¦å­˜åœ¨åŒºåˆ†
export function addOrUpdateBroadband(data) {
  return request({
    url: "/broadband/addOrUpdate",
    method: "post",
    data,
  });
}
// åˆ é™¤ï¼šbody ä¸º id æ•°ç»„
export function delBroadband(ids) {
  return request({
    url: "/broadband/del",
    method: "delete",
    data: Array.isArray(ids) ? ids : [ids],
  });
}
src/api/collaborativeApproval/houseLeaseManagement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢ï¼ˆwarnStatus: normal æ­£å¸¸ / expiring å³å°†åˆ°æœŸ / overdue å·²é€¾æœŸï¼‰
export function houseLeaseListPage(query) {
  return request({
    url: "/houseLease/listPage",
    method: "get",
    params: query,
  });
}
export function getHouseLeaseDetail(id) {
  return request({
    url: "/houseLease/getDetail",
    method: "get",
    params: { id },
  });
}
// æ–°å¢žä¸Žä¿®æ”¹å…±ç”¨ï¼Œé  id æ˜¯å¦å­˜åœ¨åŒºåˆ†ï¼›æœˆåº¦è´¹ç”¨å­è¡¨éš costList ä¸€èµ·æäº¤
export function addOrUpdateHouseLease(data) {
  return request({
    url: "/houseLease/addOrUpdate",
    method: "post",
    data,
  });
}
// åˆ é™¤ï¼šbody ä¸º id æ•°ç»„
export function delHouseLease(ids) {
  return request({
    url: "/houseLease/del",
    method: "delete",
    data: Array.isArray(ids) ? ids : [ids],
  });
}
src/api/inventoryManagement/vehicle.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
import request from "@/utils/request";
// ---------------- è½¦è¾†æ¡£æ¡ˆ ----------------
export function listVehicleInfo(query) {
  return request({
    url: "/vehicle/info/listPage",
    method: "get",
    params: query,
  });
}
// 30 å¤©å†…年检/保险到期的车辆,无参数,返回数组
export function getVehicleWarningList() {
  return request({
    url: "/vehicle/info/warningList",
    method: "get",
  });
}
export function addVehicleInfo(data) {
  return request({
    url: "/vehicle/info",
    method: "post",
    data,
  });
}
export function updateVehicleInfo(data) {
  return request({
    url: "/vehicle/info",
    method: "put",
    data,
  });
}
export function delVehicleInfo(id) {
  return request({
    url: `/vehicle/info/${id}`,
    method: "delete",
  });
}
// ---------------- è½¦è¾†è´¹ç”¨ ----------------
// ç®¡ç†ç«¯æ²¡æœ‰å•独的费用 api æ–‡ä»¶ï¼Œç›´æŽ¥å†…联 request,这里补齐
export function listVehicleCost(query) {
  return request({
    url: "/vehicle/cost/listPage",
    method: "get",
    params: query,
  });
}
export function addVehicleCost(data) {
  return request({
    url: "/vehicle/cost",
    method: "post",
    data,
  });
}
export function delVehicleCost(id) {
  return request({
    url: `/vehicle/cost/${id}`,
    method: "delete",
  });
}
// ---------------- åŠ æ²¹å¡ ----------------
export function listFuelCardPage(query) {
  return request({
    url: "/vehicle/fuelCard/listPage",
    method: "get",
    params: query,
  });
}
// å…¨é‡å¡åˆ—表(不分页),用于加油记录的下拉与余额展示
export function listAllFuelCard() {
  return request({
    url: "/vehicle/fuelCard/listAll",
    method: "get",
  });
}
export function addFuelCard(data) {
  return request({
    url: "/vehicle/fuelCard",
    method: "post",
    data,
  });
}
export function updateFuelCard(data) {
  return request({
    url: "/vehicle/fuelCard",
    method: "put",
    data,
  });
}
export function delFuelCard(id) {
  return request({
    url: `/vehicle/fuelCard/${id}`,
    method: "delete",
  });
}
// ---------------- åŠ æ²¹å¡å……å€¼ ----------------
export function listRecharge(query) {
  return request({
    url: "/vehicle/fuelCardRecharge/list",
    method: "get",
    params: query,
  });
}
export function addRecharge(data) {
  return request({
    url: "/vehicle/fuelCardRecharge/add",
    method: "post",
    data,
  });
}
export function delRecharge(id) {
  return request({
    url: `/vehicle/fuelCardRecharge/${id}`,
    method: "delete",
  });
}
// ---------------- åŠ æ²¹è®°å½• ----------------
export function listFuelRecord(query) {
  return request({
    url: "/vehicle/fuelRecord/listPage",
    method: "get",
    params: query,
  });
}
export function addFuelRecord(data) {
  return request({
    url: "/vehicle/fuelRecord",
    method: "post",
    data,
  });
}
export function updateFuelRecord(data) {
  return request({
    url: "/vehicle/fuelRecord",
    method: "put",
    data,
  });
}
export function delFuelRecord(id) {
  return request({
    url: `/vehicle/fuelRecord/${id}`,
    method: "delete",
  });
}
// æäº¤ï¼šä»ŽåŠ æ²¹å¡æ‰£å‡ä½™é¢ã€‚æ—  body çš„ POST
export function submitFuelRecord(id) {
  return request({
    url: `/vehicle/fuelRecord/submit/${id}`,
    method: "post",
  });
}
// æ’¤å›žï¼šé€€è¿˜åŠ æ²¹å¡ä½™é¢ã€‚æ—  body çš„ POST
export function revokeFuelRecord(id) {
  return request({
    url: `/vehicle/fuelRecord/revoke/${id}`,
    method: "post",
  });
}
src/api/projectManagement/config.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
import request from "@/utils/request";
// é¡¹ç›®ç®¡ç†å…¨å±€é…ç½®ï¼ˆåˆåŒåŽŸä»¶å›žä¼ é¢„è­¦æå‰å¤©æ•°ç­‰ï¼‰
export function getProjectConfig() {
  return request({
    url: "/projectManagement/config",
    method: "get",
  });
}
export function updateProjectConfig(data) {
  return request({
    url: "/projectManagement/config",
    method: "put",
    data,
  });
}
src/api/projectManagement/project.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢é¡¹ç›®ï¼ˆæ–°å¢žä¸Žç¼–辑共用 /save,靠 info.id åŒºåˆ†ï¼‰
export function listProject(data) {
  return request({
    url: "/projectManagement/info/listPage",
    method: "post",
    data,
  });
}
// æŸ¥è¯¢é¡¹ç›®è¯¦æƒ…(id åœ¨è·¯å¾„上,POST)
export function getProject(id) {
  return request({
    url: `/projectManagement/info/${id}`,
    method: "post",
  });
}
// æ–°å¢ž / ç¼–辑项目
export function saveProject(data) {
  return request({
    url: "/projectManagement/info/save",
    method: "post",
    data,
  });
}
// åˆ é™¤é¡¹ç›®ï¼ˆbody ä¸º id æ•°ç»„)
export function delProject(ids) {
  return request({
    url: "/projectManagement/info/remove",
    method: "delete",
    data: ids,
  });
}
// æ›´æ–°å•据状态
export function updateStatus(data) {
  return request({
    url: "/projectManagement/info/updateStatus",
    method: "post",
    data,
  });
}
// æäº¤ï¼šreviewStatus ç½® 0
export function submitProject(data) {
  return request({
    url: "/projectManagement/info/updateStatus",
    method: "post",
    data: { ...data, reviewStatus: 0 },
  });
}
// å®¡æ ¸ï¼šreviewStatus ç½® 1
export function auditProject(data) {
  return request({
    url: "/projectManagement/info/updateStatus",
    method: "post",
    data: { ...data, reviewStatus: 1 },
  });
}
// é˜¶æ®µï¼ˆè¿›åº¦æ±‡æŠ¥ï¼‰
export function saveStage(data) {
  return request({
    url: "/projectManagement/info/saveStage",
    method: "post",
    data,
  });
}
export function listStage(projectId) {
  return request({
    url: `/projectManagement/info/listStage/${projectId}`,
    method: "post",
  });
}
export function deleteStage(stageId) {
  return request({
    url: `/projectManagement/info/deleteStage/${stageId}`,
    method: "post",
  });
}
// é¡¹ç›®å½’档附件
export function getProjectAttachments(id) {
  return request({
    url: `/projectManagement/info/attachments/${id}`,
    method: "get",
  });
}
src/api/projectManagement/projectType.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import request from "@/utils/request";
// é¡¹ç›®ç±»åž‹ï¼ˆç®¡ç†ç«¯ã€Œè®¡åˆ’」)下拉
export function listPlan(data) {
  return request({
    url: "/projectManagement/plan/listPage",
    method: "post",
    data,
  });
}
src/api/projectManagement/role.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import request from "@/utils/request";
// é¡¹ç›®å›¢é˜Ÿè§’色下拉
export function findRoleListPage(query) {
  return request({
    url: "/projectManagement/roles/listPage",
    method: "get",
    params: query,
  });
}
src/api/qualityManagement/inspectionReport.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
import request from "@/utils/request";
// æŸ¥è¯¢æŠ¥å‘Šåˆ—表
export function listInspectionReport(query) {
  return request({
    url: "/quality/inspectionReport/listPage",
    method: "get",
    params: query,
  });
}
// æŸ¥è¯¢æŠ¥å‘Šè¯¦ç»†
export function getInspectionReport(id) {
  return request({
    url: `/quality/inspectionReport/${id}`,
    method: "get",
  });
}
// æ–°å¢žæŠ¥å‘Š
export function addInspectionReport(data) {
  return request({
    url: "/quality/inspectionReport",
    method: "post",
    data,
  });
}
// ä¿®æ”¹æŠ¥å‘Š
export function updateInspectionReport(data) {
  return request({
    url: "/quality/inspectionReport",
    method: "put",
    data,
  });
}
// æ³¨é”€ä½œåºŸï¼ˆåŽç«¯å…¥å‚为逗号分隔的 id ä¸²ï¼‰
export function delInspectionReport(ids) {
  return request({
    url: `/quality/inspectionReport/${ids}`,
    method: "delete",
  });
}
src/api/qualityManagement/qualityInspectFile.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
/**
 * å‡ºåŽ‚æ£€éªŒæŠ¥å‘Šæ–‡ä»¶ï¼ˆéœ€æ±‚ä¹.4)
 *
 * /quality/qualityInspect/downPdf æ˜¯ POST + body è¿”回二进制,而:
 *   request.ts ä¸é€ä¼  responseType(会把二进制当 JSON è§£æžï¼‰
 *   uni.downloadFile åªæ”¯æŒ GET
 * æ‰€ä»¥è¿™é‡Œä¸èµ° request,直接交给 utils/file çš„ downloadFileByPost è½ç›˜å† openDocument。
 */
import { downloadFileByPost } from "@/utils/file";
export function downloadQualityInspectPdf(id, fileName = "出厂检验报告.pdf") {
  return downloadFileByPost("/quality/qualityInspect/downPdf", { id }, fileName);
}
src/api/salesManagement/biddingManagement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢ï¼ˆæŠ•标日期区间参数为 bidDateStart / bidDateEnd)
export function biddingListPage(query) {
  return request({
    url: "/sales/bidding/listPage",
    method: "get",
    params: query,
  });
}
export function getBiddingDetail(id) {
  return request({
    url: "/sales/bidding/getDetail",
    method: "get",
    params: { id },
  });
}
// æ–°å¢žä¸Žä¿®æ”¹å…±ç”¨ï¼Œé  id æ˜¯å¦å­˜åœ¨åŒºåˆ†
export function addOrUpdateBidding(data) {
  return request({
    url: "/sales/bidding/addOrUpdate",
    method: "post",
    data,
  });
}
// åˆ é™¤ï¼šbody ä¸º id æ•°ç»„
export function delBidding(ids) {
  return request({
    url: "/sales/bidding/del",
    method: "delete",
    data: Array.isArray(ids) ? ids : [ids],
  });
}
src/api/salesManagement/contractManagement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢
export function contractListPage(query) {
  return request({
    url: "/sales/contract/listPage",
    method: "get",
    params: query,
  });
}
// ä¸åˆ†é¡µçš„合同精简列表,供诉讼管理等模块的「关联销售合同」下拉使用
export function contractListNoPage(params) {
  return request({
    url: "/sales/contract/listNoPage",
    method: "get",
    params,
  });
}
export function getContractDetail(id) {
  return request({
    url: "/sales/contract/getDetail",
    method: "get",
    params: { id },
  });
}
// æ–°å¢žä¸Žä¿®æ”¹å…±ç”¨ï¼Œé  id æ˜¯å¦å­˜åœ¨åŒºåˆ†
export function addOrUpdateContract(data) {
  return request({
    url: "/sales/contract/addOrUpdate",
    method: "post",
    data,
  });
}
// åˆ é™¤ï¼šbody ä¸º id æ•°ç»„
export function delContract(ids) {
  return request({
    url: "/sales/contract/del",
    method: "delete",
    data: Array.isArray(ids) ? ids : [ids],
  });
}
src/api/salesManagement/deliveryLedger.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢ï¼ˆå‘货日期区间参数为 beginShippingDate / endShippingDate)
export function deliveryLedgerListPage(query) {
  return request({
    url: "/shippingInfo/listPage",
    method: "get",
    params: query,
  });
}
// è¯¦æƒ…(后端路径就是 getDateil,勿"纠正"成 getDetail)
export function getDeliveryDetail(id) {
  return request({
    url: `/shippingInfo/getDateil/${id}`,
    method: "get",
  });
}
// å‘货台账修改
export function addOrUpdateDeliveryLedger(data) {
  return request({
    url: "/shippingInfo/update",
    method: "post",
    data,
  });
}
// è¡¥å……发货信息后提交(会扣减库存)
export function deductStock(data) {
  return request({
    url: "/shippingInfo/deductStock",
    method: "post",
    data,
  });
}
// åˆ é™¤ï¼šbody ä¸º id æ•°ç»„
export function delDeliveryLedger(ids) {
  return request({
    url: "/shippingInfo/delete",
    method: "delete",
    data: Array.isArray(ids) ? ids : [ids],
  });
}
src/api/salesManagement/indicatorStats.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
// å®¢æˆ·å¾€æ¥ï¼ˆéœ€æ±‚七):项目 â†’ å®¢æˆ· â†’ åˆåŒ ä¸‰çº§å¾€æ¥æ±‡æ€»ï¼Œä¸Žç®¡ç†ç«¯ salesManagement/receiptPaymentLedger åŒä¸€å¥—接口
import request from "@/utils/request";
// é¡¶éƒ¨ç»Ÿè®¡æ¡
export function transactionsSummary(query) {
  return request({
    url: "/metricStatistics/transactionsSummary",
    method: "get",
    params: query,
  });
}
// ç¬¬ä¸€çº§ï¼šé¡¹ç›®å¾€æ¥æ±‡æ€»
export function projectTransactions(query) {
  return request({
    url: "/metricStatistics/projectTransactions",
    method: "get",
    params: query,
  });
}
// ç¬¬äºŒçº§ï¼šé¡¹ç›®ä¸‹çš„客户往来
export function customewTransactions(query) {
  return request({
    url: "/metricStatistics/customewTransactions",
    method: "get",
    params: query,
  });
}
// ç¬¬ä¸‰çº§ï¼šå®¢æˆ·ä¸‹çš„合同往来明细
export function customewTransactionsDetails(query) {
  return request({
    url: "/metricStatistics/customewTransactionsDetails",
    method: "get",
    params: query,
  });
}
src/api/salesManagement/lawsuitManagement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢ï¼ˆç«‹æ¡ˆæ—¶é—´åŒºé—´å‚数为 filingDateStart / filingDateEnd)
export function lawsuitListPage(query) {
  return request({
    url: "/sales/lawsuit/listPage",
    method: "get",
    params: query,
  });
}
export function getLawsuitDetail(id) {
  return request({
    url: "/sales/lawsuit/getDetail",
    method: "get",
    params: { id },
  });
}
// æ–°å¢žä¸Žä¿®æ”¹å…±ç”¨ï¼Œé  id æ˜¯å¦å­˜åœ¨åŒºåˆ†
export function addOrUpdateLawsuit(data) {
  return request({
    url: "/sales/lawsuit/addOrUpdate",
    method: "post",
    data,
  });
}
// åˆ é™¤ï¼šbody ä¸º id æ•°ç»„
export function delLawsuit(ids) {
  return request({
    url: "/sales/lawsuit/del",
    method: "delete",
    data: Array.isArray(ids) ? ids : [ids],
  });
}
src/components/FilterPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,241 @@
<!--
  å¤šæ¡ä»¶æŸ¥è¯¢å¼¹å±‚(schema é©±åŠ¨ï¼‰
  <FilterPanel v-model:show="showFilter" :model-value="query" :fields="filterFields"
               @search="onSearch" @reset="onReset" />
  æ³¨æ„ï¼šquery ç”¨ reactive å®šä¹‰æ—¶ä¸è¦å†™ v-model="query"。
  ç»„件回吐的是整个筛选对象,reactive å¸¸é‡ä¸å¯æ•´ä½“重新赋值
  ï¼ˆä¼šç¼–译成 `query = $event`,运行时抛 Assignment to constant variable)。
  ç”¨ :model-value ä¼ å…¥ + åœ¨ @search/@reset é‡Œ Object.assign(query, values) å†™å›žï¼š
    const query = reactive({ projectName: '', bidResult: '' });
    const onSearch = values => { Object.assign(query, values); getList(); };
  é¡µé¢è‹¥å¦æœ‰æœç´¢è¾“入框绑到 query çš„æŸä¸ªå­—段,两边会天然同步(同一个对象)。
  fields æ”¯æŒçš„类型:
    { prop: 'projectName', label: '项目名称', type: 'input' }
    { prop: 'bidResult',   label: '招投标结果', type: 'select', options: [...] }
    { prop: 'filingDate',  label: '立案时间',   type: 'date' }
    { prop: 'bidDateRange', label: '投标日期',  type: 'daterange' }
  daterange ç±»åž‹çš„值是 [开始, ç»“束] ä¸¤å…ƒç´ æ•°ç»„,页面在提交前自行拆成后端要的参数名
  ï¼ˆå„模块的区间参数名不统一,如 bidDateStart/bidDateEnd、beginShippingDate/endShippingDate)。
-->
<template>
  <up-popup :show="show"
            mode="bottom"
            :round="16"
            @close="close">
    <view class="filter-panel">
      <view class="filter-panel__header">
        <text class="filter-panel__title">{{ title }}</text>
        <u-icon name="close"
                size="20"
                color="#909399"
                @click="close"></u-icon>
      </view>
      <scroll-view scroll-y
                   class="filter-panel__body">
        <view v-for="field in fields"
              :key="field.prop"
              class="filter-panel__field">
          <text class="filter-panel__label">{{ field.label }}</text>
          <view class="filter-panel__control">
            <u-input v-if="field.type === 'input'"
                     v-model="local[field.prop]"
                     :placeholder="field.placeholder || '请输入'"
                     clearable />
            <FormPicker v-else-if="field.type === 'select'"
                        v-model="local[field.prop]"
                        type="select"
                        :options="field.options || []"
                        :title="`选择${field.label}`"
                        :placeholder="field.placeholder || '请选择'" />
            <FormPicker v-else-if="field.type === 'date'"
                        v-model="local[field.prop]"
                        type="date"
                        :placeholder="field.placeholder || '请选择'" />
            <view v-else-if="field.type === 'daterange'"
                  class="filter-panel__range">
              <FormPicker v-model="local[field.prop][0]"
                          type="date"
                          placeholder="开始日期" />
              <text class="filter-panel__range-sep">至</text>
              <FormPicker v-model="local[field.prop][1]"
                          type="date"
                          placeholder="结束日期" />
            </view>
          </view>
        </view>
      </scroll-view>
      <view class="filter-panel__footer">
        <u-button class="filter-panel__btn"
                  @click="reset">重置</u-button>
        <u-button class="filter-panel__btn"
                  type="primary"
                  @click="search">查询</u-button>
      </view>
    </view>
  </up-popup>
</template>
<script setup>
  import { ref, watch } from "vue";
  import FormPicker from "@/components/FormPicker.vue";
  const props = defineProps({
    show: {
      type: Boolean,
      default: false,
    },
    modelValue: {
      type: Object,
      default: () => ({}),
    },
    fields: {
      type: Array,
      default: () => [],
    },
    title: {
      type: String,
      default: "筛选",
    },
  });
  const emit = defineEmits(["update:show", "update:modelValue", "search", "reset"]);
  const buildEmpty = () => {
    const empty = {};
    props.fields.forEach(field => {
      empty[field.prop] = field.type === "daterange" ? [] : "";
    });
    return empty;
  };
  // å¿…须先用 buildEmpty() é“ºæ»¡å­—段:弹层即使隐藏时也会渲染子节点,
  // daterange çš„ v-model ä¼šå¯¹ local[prop][0] å–值,local[prop] ä¸º undefined ä¼šç›´æŽ¥æŠ¥é”™
  const local = ref(buildEmpty());
  const syncFromParent = () => {
    const source = props.modelValue || {};
    const next = buildEmpty();
    props.fields.forEach(field => {
      const value = source[field.prop];
      if (field.type === "daterange") {
        next[field.prop] = Array.isArray(value) ? [...value] : [];
      } else if (value !== undefined && value !== null) {
        next[field.prop] = value;
      }
    });
    local.value = next;
  };
  watch(
    () => props.show,
    show => {
      if (show) syncFromParent();
    },
    { immediate: true }
  );
  // fields æ˜¯å¼‚步拿到的场景:补一次铺底,避免字段缺失
  watch(
    () => props.fields,
    () => {
      if (props.show) {
        syncFromParent();
      } else {
        local.value = buildEmpty();
      }
    },
    { deep: true }
  );
  const close = () => {
    emit("update:show", false);
  };
  const search = () => {
    emit("update:modelValue", { ...local.value });
    emit("search", { ...local.value });
    close();
  };
  const reset = () => {
    local.value = buildEmpty();
    emit("update:modelValue", { ...local.value });
    emit("reset", { ...local.value });
  };
</script>
<style scoped lang="scss">
  .filter-panel {
    background: #ffffff;
    padding: 16px;
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
  }
  .filter-panel__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding-bottom: 12px;
    border-bottom: 1px solid #f0f0f0;
  }
  .filter-panel__title {
    font-size: 16px;
    font-weight: 600;
    color: #303133;
  }
  .filter-panel__body {
    max-height: 55vh;
    padding: 8px 0;
  }
  .filter-panel__field {
    display: flex;
    align-items: center;
    padding: 10px 0;
    border-bottom: 1px solid #f8f8f8;
  }
  .filter-panel__label {
    width: 90px;
    flex-shrink: 0;
    font-size: 14px;
    color: #606266;
  }
  .filter-panel__control {
    flex: 1;
    min-width: 0;
  }
  .filter-panel__range {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .filter-panel__range-sep {
    flex-shrink: 0;
    font-size: 13px;
    color: #909399;
  }
  .filter-panel__footer {
    display: flex;
    gap: 12px;
    padding-top: 16px;
  }
  .filter-panel__btn {
    flex: 1;
  }
</style>
src/components/FormPicker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,184 @@
<!--
  é€šç”¨è¡¨å•选择项
  æ”¾åœ¨ u-form-item é‡Œä½¿ç”¨ï¼Œæ›¿ä»£å„页面手写的「只读 u-input + up-action-sheet/up-datetime-picker」组合。
  ä¸‹æ‹‰ï¼š
    <u-form-item label="所属项目" prop="projectId" required border-bottom>
      <FormPicker v-model="form.projectId" :display-text="form.projectName"
                  type="select" title="选择项目" :options="projectOptions"
                  @change="onProjectChange" />
    </u-form-item>
  æ—¥æœŸï¼š
    <u-form-item label="立案时间" prop="filingDate" required border-bottom>
      <FormPicker v-model="form.filingDate" type="date" placeholder="请选择立案时间" />
    </u-form-item>
  options æ”¯æŒä¸‰ç§å†™æ³•,取值优先级:
    æ˜¾ç¤ºæ–‡æœ¬  item.name â†’ item.text â†’ item.label â†’ item.displayText
    å®žé™…值    item.value â†’ item.id â†’ item.code
  ä¹Ÿå¯ä»¥ç”¨ label-key / value-key æ˜¾å¼æŒ‡å®šã€‚
-->
<template>
  <view class="form-picker">
    <u-input :modelValue="text"
             :placeholder="placeholder"
             :disabled="disabled"
             :readonly="true"
             :clearable="clearable && !disabled && !readonly"
             @click="open"
             @update:modelValue="onInputUpdate">
      <template #suffix>
        <u-icon v-if="!disabled && !readonly"
                name="arrow-right"
                color="#c0c4cc"
                @click="open"></u-icon>
      </template>
    </u-input>
    <up-action-sheet v-if="type === 'select'"
                     :show="showSheet"
                     :actions="actions"
                     :title="title"
                     @select="onSelect"
                     @close="showSheet = false"></up-action-sheet>
    <up-datetime-picker v-if="type === 'date'"
                        :show="showPicker"
                        v-model="pickerValue"
                        :mode="dateMode"
                        @confirm="onDateConfirm"
                        @cancel="showPicker = false"></up-datetime-picker>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { parseDate } from "@/utils/warning";
  const props = defineProps({
    modelValue: {
      type: [String, Number, Boolean, null],
      default: "",
    },
    // select ä¸‹æ‹‰ | date æ—¥æœŸ
    type: {
      type: String,
      default: "select",
    },
    // ç›´æŽ¥æŒ‡å®šæ˜¾ç¤ºæ–‡æœ¬ï¼›ä¸ä¼ åˆ™æŒ‰ options åæŸ¥
    displayText: {
      type: String,
      default: "",
    },
    options: {
      type: Array,
      default: () => [],
    },
    title: {
      type: String,
      default: "",
    },
    placeholder: {
      type: String,
      default: "请选择",
    },
    labelKey: {
      type: String,
      default: "",
    },
    valueKey: {
      type: String,
      default: "",
    },
    // æ—¥æœŸç²¾åº¦ï¼šdate | datetime | time | year-month
    dateMode: {
      type: String,
      default: "date",
    },
    disabled: {
      type: Boolean,
      default: false,
    },
    readonly: {
      type: Boolean,
      default: false,
    },
    clearable: {
      type: Boolean,
      default: true,
    },
  });
  const emit = defineEmits(["update:modelValue", "change"]);
  const showSheet = ref(false);
  const showPicker = ref(false);
  const pickerValue = ref(Date.now());
  const optionValue = item =>
    props.valueKey
      ? item?.[props.valueKey]
      : (item?.value ?? item?.id ?? item?.code);
  const optionLabel = item =>
    props.labelKey
      ? item?.[props.labelKey]
      : (item?.name ?? item?.text ?? item?.label ?? item?.displayText ?? "");
  const actions = computed(() =>
    (props.options || []).map(item => ({
      text: String(optionLabel(item) ?? ""),
      value: optionValue(item),
      data: item,
    }))
  );
  const text = computed(() => {
    if (props.displayText) return props.displayText;
    if (props.modelValue === undefined || props.modelValue === null) return "";
    if (props.type === "date") return String(props.modelValue);
    const matched = (props.options || []).find(
      item => String(optionValue(item)) === String(props.modelValue)
    );
    return matched ? String(optionLabel(matched)) : "";
  });
  const open = () => {
    if (props.disabled || props.readonly) return;
    if (props.type === "select") {
      showSheet.value = true;
      return;
    }
    const base = parseDate(props.modelValue);
    pickerValue.value = base ? base.getTime() : Date.now();
    showPicker.value = true;
  };
  // ç”¨æˆ·ç‚¹å‡» u-input çš„æ¸…除按钮时,把空值同步出去
  const onInputUpdate = value => {
    if (value !== "" && value !== undefined) return;
    emit("update:modelValue", "");
    emit("change", "", null);
  };
  const onSelect = item => {
    showSheet.value = false;
    emit("update:modelValue", item.value);
    emit("change", item.value, item.data);
  };
  const onDateConfirm = e => {
    showPicker.value = false;
    const value = formatDateToYMD(e.value);
    emit("update:modelValue", value);
    emit("change", value, null);
  };
</script>
<style scoped lang="scss">
  .form-picker {
    width: 100%;
  }
</style>
src/pages.json
@@ -1486,6 +1486,265 @@
        "navigationBarTitleText": "公告通知",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/projectManagement/project/index",
      "style": {
        "navigationBarTitleText": "项目管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/projectManagement/project/detail",
      "style": {
        "navigationBarTitleText": "项目详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/projectManagement/project/edit",
      "style": {
        "navigationBarTitleText": "项目编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/projectManagement/project/archive",
      "style": {
        "navigationBarTitleText": "文档归档",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/projectManagement/project/progress",
      "style": {
        "navigationBarTitleText": "进度汇报",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/projectManagement/project/config",
      "style": {
        "navigationBarTitleText": "预警配置",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/biddingManagement/index",
      "style": {
        "navigationBarTitleText": "招投标管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/biddingManagement/detail",
      "style": {
        "navigationBarTitleText": "招投标详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/biddingManagement/edit",
      "style": {
        "navigationBarTitleText": "招投标编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/lawsuitManagement/index",
      "style": {
        "navigationBarTitleText": "诉讼管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/lawsuitManagement/detail",
      "style": {
        "navigationBarTitleText": "诉讼详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/lawsuitManagement/edit",
      "style": {
        "navigationBarTitleText": "诉讼编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/deliveryLedger/index",
      "style": {
        "navigationBarTitleText": "发货台账",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/deliveryLedger/detail",
      "style": {
        "navigationBarTitleText": "发货详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/deliveryLedger/edit",
      "style": {
        "navigationBarTitleText": "补充发货信息",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/contractManagement/index",
      "style": {
        "navigationBarTitleText": "合同管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/contractManagement/detail",
      "style": {
        "navigationBarTitleText": "合同详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/sales/contractManagement/edit",
      "style": {
        "navigationBarTitleText": "合同编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/qualityManagement/inspectionReport/index",
      "style": {
        "navigationBarTitleText": "报表管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/qualityManagement/inspectionReport/detail",
      "style": {
        "navigationBarTitleText": "报告详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/qualityManagement/inspectionReport/edit",
      "style": {
        "navigationBarTitleText": "报告编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/vehicleInfo/index",
      "style": {
        "navigationBarTitleText": "车辆管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/vehicleInfo/detail",
      "style": {
        "navigationBarTitleText": "车辆详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/vehicleInfo/edit",
      "style": {
        "navigationBarTitleText": "车辆编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/fuelCard/index",
      "style": {
        "navigationBarTitleText": "加油卡管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/fuelCard/detail",
      "style": {
        "navigationBarTitleText": "加油卡详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/fuelCard/edit",
      "style": {
        "navigationBarTitleText": "加油卡编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/fuelCard/recharge",
      "style": {
        "navigationBarTitleText": "加油卡充值",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/fuelRecord/index",
      "style": {
        "navigationBarTitleText": "加油记录",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/fuelRecord/detail",
      "style": {
        "navigationBarTitleText": "加油记录详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/fuelRecord/edit",
      "style": {
        "navigationBarTitleText": "加油记录编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/broadbandCommunication/index",
      "style": {
        "navigationBarTitleText": "宽带与通信管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/broadbandCommunication/detail",
      "style": {
        "navigationBarTitleText": "宽带与通信详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/broadbandCommunication/edit",
      "style": {
        "navigationBarTitleText": "宽带与通信编辑",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/houseLeaseManagement/index",
      "style": {
        "navigationBarTitleText": "房屋租赁管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/houseLeaseManagement/detail",
      "style": {
        "navigationBarTitleText": "房屋租赁详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/houseLeaseManagement/edit",
      "style": {
        "navigationBarTitleText": "房屋租赁编辑",
        "navigationStyle": "custom"
      }
    }
  ],
  "subPackages": [
src/pages/basicData/customerFile/edit.vue
@@ -199,9 +199,10 @@
    customerType: [{ required: true, message: "请选择客户分类", trigger: "change" }],
  };
  // ä¸ŽåŽç«¯/管理端取值保持一致(管理端下拉为「零售客户 / è¿›é”€å•†å®¢æˆ·ã€ï¼‰
  const customerTypeActions = [
    { name: "零售客户", value: "零售客户" },
    { name: "经销商客户", value: "经销商客户" },
    { name: "进销商客户", value: "进销商客户" },
  ];
  const pageTitle = computed(() =>
src/pages/basicData/customerFile/index.vue
@@ -102,10 +102,12 @@
  const customerName = ref("");
  const list = ref([]);
  // å®¢æˆ·åˆ†ç±»å–值必须与后端一致(管理端下拉为「零售客户 / è¿›é”€å•†å®¢æˆ·ã€ï¼‰ï¼Œ
  // å†™æˆã€Œç»é”€å•†å®¢æˆ·ã€ä¼šç­›ä¸å‡ºä»»ä½•数据
  const tabList = reactive([
    { name: "全部客户", value: "" },
    { name: "零售客户", value: "零售客户" },
    { name: "经销商客户", value: "经销商客户" },
    { name: "进销商客户", value: "进销商客户" },
  ]);
  const tabValue = ref(0);
src/pages/cooperativeOffice/broadbandCommunication/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,206 @@
<template>
  <view class="broadband-detail">
    <PageHeader title="宽带与通信详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <text class="hero-title">{{ detail.office || "-" }}</text>
        <up-tag :text="warn.text || '无缴费日期'"
                :type="warnTagType(warn.level)"
                size="mini"
                shape="circle" />
      </view>
      <view v-if="warn.text"
            class="warn-banner"
            :style="{ background: warn.level === 'normal' ? '#f0f9eb' : '#fef0f0' }">
        <text class="warn-banner-text"
              :style="{ color: warn.color }">缴费状态:{{ warn.text }}(下次缴费 {{ detail.nextPaymentDate || "-" }})</text>
      </view>
      <u-cell-group title="安装位置"
                    class="detail-section">
        <u-cell title="区域"
                :value="detail.area || '-'" />
        <u-cell title="办事处"
                :value="detail.office || '-'" />
        <u-cell title="驻省人员"
                :value="detail.residentStaff || '-'" />
        <u-cell title="地址"
                :value="detail.address || '-'" />
      </u-cell-group>
      <u-cell-group title="安装与缴费"
                    class="detail-section">
        <u-cell title="安装日期"
                :value="detail.installDate || '-'" />
        <u-cell title="安装费"
                :value="formatAmount(detail.installFee)" />
        <u-cell title="缴费周期"
                :value="cycleText(detail.paymentCycle)" />
        <u-cell title="缴费方式"
                :value="detail.paymentMethod || '-'" />
        <u-cell title="预存"
                :value="formatAmount(detail.prepaidAmount)" />
        <u-cell title="资费"
                :value="formatAmount(detail.monthlyFee)" />
        <u-cell title="下次缴费日期"
                :value="detail.nextPaymentDate || '-'" />
      </u-cell-group>
      <u-cell-group title="套餐内容"
                    class="detail-section">
        <u-cell title="套餐内容"
                :value="detail.packageContent || '-'" />
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { loadDetailData } from "@/utils/detailCache";
  import { getWarnInfo } from "@/utils/warning";
  import { cycleText } from "@/utils/paymentCycle";
  import { getBroadbandDetail } from "@/api/collaborativeApproval/broadbandCommunication";
  // å®½å¸¦åˆ°æœŸæå‰ 15 å¤©é¢„è­¦
  const WARN_DAYS = 15;
  const loading = ref(true);
  const broadbandId = ref("");
  const detail = ref({});
  const warn = computed(() =>
    getWarnInfo(detail.value.nextPaymentDate, WARN_DAYS)
  );
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("broadbandDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/cooperativeOffice/broadbandCommunication/edit?id=${broadbandId.value}`,
    });
  };
  const warnTagType = level => {
    if (level === "overdue") return "error";
    if (level === "warning") return "warning";
    if (level === "normal") return "success";
    return "info";
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const loadDetail = async () => {
    loading.value = true;
    try {
      const data = await loadDetailData({
        cacheKey: "broadbandDetail",
        id: broadbandId.value,
        fetcher: getBroadbandDetail,
      });
      detail.value = data || {};
    } catch (error) {
      uni.showToast({ title: "获取宽带与通信详情失败", icon: "none" });
      detail.value = {};
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    broadbandId.value = options?.id || "";
    if (!broadbandId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .broadband-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #f4f1ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(124, 92, 252, 0.1);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .warn-banner {
    margin-top: 12px;
    padding: 10px 14px;
    border-radius: 10px;
  }
  .warn-banner-text {
    font-size: 13px;
    line-height: 1.5;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/cooperativeOffice/broadbandCommunication/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,316 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="安装位置"
                      class="form-section">
          <up-form-item label="区域"
                        prop="area"
                        required
                        border-bottom>
            <up-input v-model="form.area"
                      placeholder="请输入区域"
                      clearable />
          </up-form-item>
          <up-form-item label="办事处"
                        prop="office"
                        required
                        border-bottom>
            <up-input v-model="form.office"
                      placeholder="请输入办事处"
                      clearable />
          </up-form-item>
          <up-form-item label="驻省人员"
                        prop="residentStaff"
                        required
                        border-bottom>
            <up-input v-model="form.residentStaff"
                      placeholder="请输入驻省人员"
                      clearable />
          </up-form-item>
          <up-form-item label="地址"
                        prop="address"
                        required>
            <up-textarea v-model="form.address"
                         placeholder="请输入详细地址"
                         auto-height
                         maxlength="200" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="安装与缴费"
                      class="form-section">
          <up-form-item label="安装日期"
                        prop="installDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.installDate"
                        type="date"
                        placeholder="请选择安装日期"
                        @change="calculateNextPayment" />
          </up-form-item>
          <up-form-item label="安装费"
                        prop="installFee"
                        border-bottom>
            <up-input v-model="form.installFee"
                      type="number"
                      placeholder="请输入安装费"
                      clearable />
          </up-form-item>
          <up-form-item label="缴费周期"
                        prop="paymentCycle"
                        required
                        border-bottom>
            <FormPicker v-model="form.paymentCycle"
                        type="select"
                        title="选择缴费周期"
                        :options="paymentCycleOptions"
                        placeholder="请选择缴费周期"
                        @change="calculateNextPayment" />
          </up-form-item>
          <up-form-item label="缴费方式"
                        prop="paymentMethod"
                        required
                        border-bottom>
            <FormPicker v-model="form.paymentMethod"
                        type="select"
                        title="选择缴费方式"
                        :options="paymentMethodOptions"
                        placeholder="请选择缴费方式" />
          </up-form-item>
          <up-form-item label="预存"
                        prop="prepaidAmount"
                        border-bottom>
            <up-input v-model="form.prepaidAmount"
                      type="number"
                      placeholder="请输入预存金额"
                      clearable />
          </up-form-item>
          <up-form-item label="资费"
                        prop="monthlyFee"
                        border-bottom>
            <up-input v-model="form.monthlyFee"
                      type="number"
                      placeholder="请输入资费"
                      clearable />
          </up-form-item>
          <up-form-item label="下次缴费日期"
                        prop="nextPaymentDate">
            <up-input v-model="form.nextPaymentDate"
                      placeholder="按安装日期与缴费周期自动计算"
                      disabled />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="套餐内容"
                      class="form-section">
          <up-form-item label="套餐内容"
                        prop="packageContent">
            <up-textarea v-model="form.packageContent"
                         placeholder="请输入套餐内容"
                         auto-height
                         maxlength="500" />
          </up-form-item>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { addMonths } from "@/utils/warning";
  import { loadDetailData } from "@/utils/detailCache";
  import {
    PAYMENT_CYCLE_OPTIONS,
    PAYMENT_METHOD_OPTIONS,
  } from "@/utils/paymentCycle";
  import {
    addOrUpdateBroadband,
    getBroadbandDetail,
  } from "@/api/collaborativeApproval/broadbandCommunication";
  const formRef = ref();
  const loading = ref(false);
  const broadbandId = ref("");
  const paymentCycleOptions = PAYMENT_CYCLE_OPTIONS;
  const paymentMethodOptions = PAYMENT_METHOD_OPTIONS;
  const form = ref({
    id: undefined,
    area: "",
    office: "",
    residentStaff: "",
    address: "",
    installDate: "",
    installFee: "",
    paymentCycle: "",
    paymentMethod: "",
    prepaidAmount: "",
    monthlyFee: "",
    packageContent: "",
    nextPaymentDate: "",
  });
  const rules = {
    area: [{ required: true, message: "请输入区域", trigger: "blur" }],
    office: [{ required: true, message: "请输入办事处", trigger: "blur" }],
    residentStaff: [
      { required: true, message: "请输入驻省人员", trigger: "blur" },
    ],
    address: [{ required: true, message: "请输入地址", trigger: "blur" }],
    installDate: [
      { required: true, message: "请选择安装日期", trigger: "change" },
    ],
    paymentCycle: [
      { required: true, message: "请选择缴费周期", trigger: "change" },
    ],
    paymentMethod: [
      { required: true, message: "请选择缴费方式", trigger: "change" },
    ],
  };
  const pageTitle = computed(() =>
    broadbandId.value ? "编辑宽带与通信" : "新增宽带与通信"
  );
  const goBack = () => uni.navigateBack();
  const numOrNull = value =>
    value === "" || value === null || value === undefined
      ? null
      : Number.isNaN(Number(value))
        ? null
        : Number(value);
  // ä¸‹æ¬¡ç¼´è´¹æ—¥æœŸ = å®‰è£…日期 + ç¼´è´¹å‘¨æœŸæœˆï¼Œåªè¯»
  const calculateNextPayment = () => {
    const months = Number(form.value.paymentCycle);
    form.value.nextPaymentDate =
      form.value.installDate && Number.isFinite(months) && months > 0
        ? addMonths(form.value.installDate, months)
        : "";
  };
  const loadDetail = async () => {
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "broadbandDetail",
        id: broadbandId.value,
        fetcher: getBroadbandDetail,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "未找到宽带与通信信息", icon: "none" });
      return;
    }
    form.value = {
      id: data.id,
      area: data.area || "",
      office: data.office || "",
      residentStaff: data.residentStaff || "",
      address: data.address || "",
      installDate: (data.installDate || "").slice(0, 10),
      installFee: data.installFee ?? "",
      paymentCycle: data.paymentCycle ?? "",
      paymentMethod: data.paymentMethod || "",
      prepaidAmount: data.prepaidAmount ?? "",
      monthlyFee: data.monthlyFee ?? "",
      packageContent: data.packageContent || "",
      nextPaymentDate: (data.nextPaymentDate || "").slice(0, 10),
    };
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    calculateNextPayment();
    const payload = {
      id: form.value.id,
      area: form.value.area,
      office: form.value.office,
      residentStaff: form.value.residentStaff,
      address: form.value.address,
      installDate: form.value.installDate,
      installFee: numOrNull(form.value.installFee),
      paymentCycle: numOrNull(form.value.paymentCycle),
      paymentMethod: form.value.paymentMethod,
      prepaidAmount: numOrNull(form.value.prepaidAmount),
      monthlyFee: numOrNull(form.value.monthlyFee),
      packageContent: form.value.packageContent,
      nextPaymentDate: form.value.nextPaymentDate || null,
    };
    loading.value = true;
    addOrUpdateBroadband(payload)
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 1000);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      broadbandId.value = options.id;
      form.value.id = options.id;
      loadDetail();
    }
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/cooperativeOffice/broadbandCommunication/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,262 @@
<template>
  <view class="sales-account">
    <PageHeader title="宽带与通信管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.area"
                    placeholder="请输入区域搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="broadbandList.length > 0"
          class="ledger-list">
      <view v-for="item in broadbandList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="wifi"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.office || "-" }}</text>
          </view>
          <up-tag :text="warnOf(item).text || '无缴费日期'"
                  :type="warnTagType(warnOf(item).level)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">区域</text>
            <text class="detail-value">{{ item.area || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">驻省人员</text>
            <text class="detail-value">{{ item.residentStaff || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">地址</text>
            <text class="detail-value">{{ item.address || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">安装日期</text>
            <text class="detail-value">{{ item.installDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">安装费</text>
            <text class="detail-value">{{ formatAmount(item.installFee) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">缴费周期</text>
            <text class="detail-value">{{ cycleText(item.paymentCycle) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">缴费方式</text>
            <text class="detail-value">{{ item.paymentMethod || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">资费</text>
            <text class="detail-value">{{ formatAmount(item.monthlyFee) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">下次缴费日期</text>
            <text class="detail-value">{{ item.nextPaymentDate || "-" }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无宽带与通信数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="宽带与通信筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    broadbandListPage,
    delBroadband,
  } from "@/api/collaborativeApproval/broadbandCommunication";
  import { unwrapList } from "@/utils/list";
  import { getWarnInfo } from "@/utils/warning";
  import { cycleText } from "@/utils/paymentCycle";
  // å®½å¸¦åˆ°æœŸæå‰ 15 å¤©é¢„è­¦
  const WARN_DAYS = 15;
  const WARN_STATUS_OPTIONS = [
    { name: "正常", value: "normal" },
    { name: "即将到期", value: "expiring" },
    { name: "已逾期", value: "overdue" },
  ];
  const broadbandList = ref([]);
  const showFilter = ref(false);
  const query = reactive({
    area: "",
    office: "",
    residentStaff: "",
    warnStatus: "",
  });
  const filterFields = [
    { prop: "area", label: "区域", type: "input", placeholder: "请输入区域" },
    { prop: "office", label: "办事处", type: "input", placeholder: "请输入办事处" },
    {
      prop: "residentStaff",
      label: "驻省人员",
      type: "input",
      placeholder: "请输入驻省人员",
    },
    {
      prop: "warnStatus",
      label: "预警状态",
      type: "select",
      options: WARN_STATUS_OPTIONS,
    },
  ];
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("broadbandDetail");
    uni.navigateTo({
      url: "/pages/cooperativeOffice/broadbandCommunication/edit",
    });
  };
  const goEdit = item => {
    uni.setStorageSync("broadbandDetail", item || {});
    uni.navigateTo({
      url: `/pages/cooperativeOffice/broadbandCommunication/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("broadbandDetail", item || {});
    uni.navigateTo({
      url: `/pages/cooperativeOffice/broadbandCommunication/detail?id=${item.id}`,
    });
  };
  const warnOf = item => getWarnInfo(item?.nextPaymentDate, WARN_DAYS);
  const warnTagType = level => {
    if (level === "overdue") return "error";
    if (level === "warning") return "warning";
    if (level === "normal") return "success";
    return "info";
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const getList = () => {
    broadbandListPage({
      current: -1,
      size: -1,
      area: query.area || undefined,
      office: query.office || undefined,
      residentStaff: query.residentStaff || undefined,
      warnStatus: query.warnStatus || undefined,
    })
      .then(res => {
        broadbandList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该宽带与通信记录吗?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delBroadband(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/cooperativeOffice/houseLeaseManagement/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,406 @@
<template>
  <view class="house-lease-detail">
    <PageHeader title="房屋租赁详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <view class="hero-left">
          <text class="hero-title">{{ detail.office || "-" }}</text>
          <text class="hero-sub">{{ detail.address || "-" }}</text>
        </view>
        <up-tag :text="warn.text || '无到期日'"
                :type="warnTagType(warn.level)"
                size="mini"
                shape="circle" />
      </view>
      <view v-if="warn.text"
            class="warn-banner"
            :style="{ background: warn.level === 'normal' ? '#f0f9eb' : '#fef0f0' }">
        <text class="warn-banner-text"
              :style="{ color: warn.color }">合同状态:{{ warn.text }}(到期日 {{ detail.contractEndDate || "-" }})</text>
      </view>
      <u-cell-group title="房屋与人员"
                    class="detail-section">
        <u-cell title="办事处"
                :value="detail.office || '-'" />
        <u-cell title="使用人"
                :value="detail.userName || '-'" />
        <u-cell title="驻省人员"
                :value="detail.residentStaff || '-'" />
        <u-cell title="地址"
                :value="detail.address || '-'" />
      </u-cell-group>
      <u-cell-group title="合同信息"
                    class="detail-section">
        <u-cell title="起租时间"
                :value="detail.rentStartDate || '-'" />
        <u-cell title="合同起租日"
                :value="detail.contractStartDate || '-'" />
        <u-cell title="合同到期日"
                :value="detail.contractEndDate || '-'" />
        <u-cell title="承租人"
                :value="detail.tenant || '-'" />
        <u-cell title="合同甲方"
                :value="detail.partyA || '-'" />
      </u-cell-group>
      <u-cell-group title="费用信息"
                    class="detail-section">
        <u-cell title="电话"
                :value="detail.phone || '-'" />
        <u-cell title="面积"
                :value="detail.area != null && detail.area !== '' ? String(detail.area) : '-'" />
        <u-cell title="支付方式"
                :value="detail.paymentMethod || '-'" />
        <u-cell title="押金"
                :value="formatAmount(detail.deposit)" />
        <u-cell title="租金(元/月)"
                :value="formatAmount(detail.rent)" />
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
    <view v-if="!loading"
          class="cost-section">
      <view class="cost-header">
        <text class="cost-header-title">月度费用</text>
        <text class="cost-header-tip">{{ costList.length }} ä¸ªæœˆ</text>
      </view>
      <scroll-view v-if="costList.length > 0"
                   scroll-x
                   class="cost-scroll">
        <view class="cost-table">
          <view class="cost-row cost-head">
            <text class="cost-cell col-month">月份</text>
            <text class="cost-cell">房租</text>
            <text class="cost-cell">水电</text>
            <text class="cost-cell">物业</text>
            <text class="cost-cell">快递</text>
            <text class="cost-cell">宽带</text>
            <text class="cost-cell">月合计</text>
          </view>
          <view v-for="row in costList"
                :key="row.month"
                class="cost-row">
            <text class="cost-cell col-month">{{ row.month }}</text>
            <text class="cost-cell">{{ money(row.rent) }}</text>
            <text class="cost-cell">{{ money(row.waterElectricity) }}</text>
            <text class="cost-cell">{{ money(row.property) }}</text>
            <text class="cost-cell">{{ money(row.express) }}</text>
            <text class="cost-cell">{{ money(row.broadband) }}</text>
            <text class="cost-cell cost-total">{{ rowTotal(row) }}</text>
          </view>
          <view class="cost-row cost-foot">
            <text class="cost-cell col-month">累计</text>
            <text class="cost-cell">{{ totals.rent }}</text>
            <text class="cost-cell">{{ totals.waterElectricity }}</text>
            <text class="cost-cell">{{ totals.property }}</text>
            <text class="cost-cell">{{ totals.express }}</text>
            <text class="cost-cell">{{ totals.broadband }}</text>
            <text class="cost-cell cost-total">{{ totals.total }}</text>
          </view>
        </view>
      </scroll-view>
      <view v-else
            class="cost-empty">
        <text>暂无月度费用数据</text>
      </view>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { loadDetailData } from "@/utils/detailCache";
  import { getWarnInfo } from "@/utils/warning";
  import { getHouseLeaseDetail } from "@/api/collaborativeApproval/houseLeaseManagement";
  // åˆåŒåˆ°æœŸæå‰ 30 å¤©é¢„è­¦
  const WARN_DAYS = 30;
  const COST_FIELDS = [
    "rent",
    "waterElectricity",
    "property",
    "express",
    "broadband",
  ];
  const loading = ref(true);
  const leaseId = ref("");
  const detail = ref({});
  const costList = ref([]);
  const warn = computed(() =>
    getWarnInfo(detail.value.contractEndDate, WARN_DAYS)
  );
  const money = value =>
    value === null || value === undefined || value === ""
      ? "-"
      : Number(value).toFixed(2);
  const rowTotal = row =>
    COST_FIELDS.reduce((sum, key) => sum + (Number(row[key]) || 0), 0).toFixed(2);
  const totals = computed(() => {
    const acc = { total: 0 };
    COST_FIELDS.forEach(key => {
      acc[key] = 0;
    });
    costList.value.forEach(row => {
      COST_FIELDS.forEach(key => {
        acc[key] += Number(row[key]) || 0;
      });
      acc.total += Number(rowTotal(row)) || 0;
    });
    const shown = { total: acc.total.toFixed(2) };
    COST_FIELDS.forEach(key => {
      shown[key] = acc[key].toFixed(2);
    });
    return shown;
  });
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("houseLeaseDetail", {
      ...detail.value,
      costList: costList.value,
    });
    uni.navigateTo({
      url: `/pages/cooperativeOffice/houseLeaseManagement/edit?id=${leaseId.value}`,
    });
  };
  const warnTagType = level => {
    if (level === "overdue") return "error";
    if (level === "warning") return "warning";
    if (level === "normal") return "success";
    return "info";
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const loadDetail = async () => {
    loading.value = true;
    try {
      const data = await loadDetailData({
        cacheKey: "houseLeaseDetail",
        id: leaseId.value,
        fetcher: getHouseLeaseDetail,
      });
      detail.value = data || {};
      const rows = data?.costList || data?.costVOs || [];
      costList.value = Array.isArray(rows) ? rows : [];
    } catch (error) {
      uni.showToast({ title: "获取房屋租赁详情失败", icon: "none" });
      detail.value = {};
      costList.value = [];
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    leaseId.value = options?.id || "";
    if (!leaseId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .house-lease-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eaf4ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(76, 154, 255, 0.1);
  }
  .hero-left {
    display: flex;
    flex-direction: column;
    gap: 6px;
    max-width: 70%;
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
  }
  .hero-sub {
    font-size: 13px;
    color: #606266;
    word-break: break-all;
  }
  .warn-banner {
    margin-top: 12px;
    padding: 10px 14px;
    border-radius: 10px;
  }
  .warn-banner-text {
    font-size: 13px;
    line-height: 1.5;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
  .cost-section {
    margin: 12px 12px 0;
    border-radius: 12px;
    background: #ffffff;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .cost-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 16px 10px;
    background: #f8fbff;
  }
  .cost-header-title {
    font-size: 15px;
    font-weight: 600;
    color: #22324d;
  }
  .cost-header-tip {
    font-size: 12px;
    color: #909399;
  }
  .cost-scroll {
    width: 100%;
    white-space: nowrap;
  }
  .cost-table {
    display: inline-block;
    min-width: 100%;
    padding-bottom: 6px;
  }
  .cost-row {
    display: flex;
    align-items: center;
    border-bottom: 1px solid #f2f4f8;
  }
  .cost-row:last-child {
    border-bottom: none;
  }
  .cost-head {
    background: #fafbfc;
  }
  .cost-head .cost-cell {
    font-size: 12px;
    font-weight: 600;
    color: #606266;
  }
  .cost-foot {
    background: #f8fbff;
  }
  .cost-foot .cost-cell {
    font-size: 13px;
    font-weight: 600;
    color: #22324d;
  }
  .cost-cell {
    width: 76px;
    flex: 0 0 76px;
    text-align: center;
    font-size: 13px;
    color: #333;
    line-height: 44px;
  }
  .col-month {
    width: 78px;
    flex: 0 0 78px;
    color: #606266;
  }
  .cost-total {
    color: #2979ff;
    font-weight: 600;
  }
  .cost-empty {
    padding: 24px 16px;
    text-align: center;
    font-size: 13px;
    color: #909399;
  }
</style>
src/pages/cooperativeOffice/houseLeaseManagement/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,603 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="房屋与人员"
                      class="form-section">
          <up-form-item label="办事处"
                        prop="office"
                        required
                        border-bottom>
            <up-input v-model="form.office"
                      placeholder="请输入办事处"
                      clearable />
          </up-form-item>
          <up-form-item label="使用人"
                        prop="userName"
                        required
                        border-bottom>
            <up-input v-model="form.userName"
                      placeholder="请输入使用人"
                      clearable />
          </up-form-item>
          <up-form-item label="驻省人员"
                        prop="residentStaff"
                        required
                        border-bottom>
            <up-input v-model="form.residentStaff"
                      placeholder="请输入驻省人员"
                      clearable />
          </up-form-item>
          <up-form-item label="地址"
                        prop="address"
                        required>
            <up-textarea v-model="form.address"
                         placeholder="请输入详细地址"
                         auto-height
                         maxlength="200" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="合同信息"
                      class="form-section">
          <up-form-item label="起租时间"
                        prop="rentStartDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.rentStartDate"
                        type="date"
                        placeholder="请选择起租时间" />
          </up-form-item>
          <up-form-item label="合同起租日"
                        prop="contractStartDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.contractStartDate"
                        type="date"
                        placeholder="请选择合同起租日"
                        @change="rebuildMonths" />
          </up-form-item>
          <up-form-item label="合同到期日"
                        prop="contractEndDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.contractEndDate"
                        type="date"
                        placeholder="请选择合同到期日"
                        @change="rebuildMonths" />
          </up-form-item>
          <up-form-item label="承租人"
                        prop="tenant"
                        required
                        border-bottom>
            <up-input v-model="form.tenant"
                      placeholder="请输入承租人"
                      clearable />
          </up-form-item>
          <up-form-item label="合同甲方"
                        prop="partyA"
                        required
                        border-bottom>
            <up-input v-model="form.partyA"
                      placeholder="请输入合同甲方"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="费用信息"
                      class="form-section">
          <up-form-item label="电话"
                        prop="phone"
                        border-bottom>
            <up-input v-model="form.phone"
                      type="number"
                      placeholder="请输入联系电话"
                      clearable />
          </up-form-item>
          <up-form-item label="面积"
                        prop="area"
                        border-bottom>
            <up-input v-model="form.area"
                      type="number"
                      placeholder="请输入面积"
                      clearable />
          </up-form-item>
          <up-form-item label="支付方式"
                        prop="paymentMethod"
                        required
                        border-bottom>
            <FormPicker v-model="form.paymentMethod"
                        type="select"
                        title="选择支付方式"
                        :options="paymentMethodOptions"
                        placeholder="请选择支付方式" />
          </up-form-item>
          <up-form-item label="押金"
                        prop="deposit"
                        border-bottom>
            <up-input v-model="form.deposit"
                      type="number"
                      placeholder="请输入押金"
                      clearable />
          </up-form-item>
          <up-form-item label="租金(元/月)"
                        prop="rent"
                        required>
            <up-input v-model="form.rent"
                      type="number"
                      placeholder="请输入租金"
                      clearable
                      @change="applyRentToMonths" />
          </up-form-item>
        </u-cell-group>
      </up-form>
    </view>
    <view class="cost-section">
      <view class="cost-header">
        <text class="cost-header-title">月度费用</text>
        <text class="cost-header-tip">{{ costHint }}</text>
      </view>
      <scroll-view v-if="costList.length > 0"
                   scroll-x
                   class="cost-scroll">
        <view class="cost-table">
          <view class="cost-row cost-head">
            <text class="cost-cell col-month">月份</text>
            <text class="cost-cell">房租</text>
            <text class="cost-cell">水电</text>
            <text class="cost-cell">物业</text>
            <text class="cost-cell">快递</text>
            <text class="cost-cell">宽带</text>
            <text class="cost-cell">月合计</text>
          </view>
          <view v-for="row in costList"
                :key="row.month"
                class="cost-row">
            <text class="cost-cell col-month">{{ row.month }}</text>
            <view class="cost-cell">
              <up-input v-model="row.rent"
                        type="number"
                        border="none"
                        input-align="center" />
            </view>
            <view class="cost-cell">
              <up-input v-model="row.waterElectricity"
                        type="number"
                        border="none"
                        input-align="center" />
            </view>
            <view class="cost-cell">
              <up-input v-model="row.property"
                        type="number"
                        border="none"
                        input-align="center" />
            </view>
            <view class="cost-cell">
              <up-input v-model="row.express"
                        type="number"
                        border="none"
                        input-align="center" />
            </view>
            <view class="cost-cell">
              <up-input v-model="row.broadband"
                        type="number"
                        border="none"
                        input-align="center" />
            </view>
            <text class="cost-cell cost-total">{{ rowTotal(row) }}</text>
          </view>
          <view class="cost-row cost-foot">
            <text class="cost-cell col-month">累计</text>
            <text class="cost-cell">{{ totals.rent }}</text>
            <text class="cost-cell">{{ totals.waterElectricity }}</text>
            <text class="cost-cell">{{ totals.property }}</text>
            <text class="cost-cell">{{ totals.express }}</text>
            <text class="cost-cell">{{ totals.broadband }}</text>
            <text class="cost-cell cost-total">{{ totals.total }}</text>
          </view>
        </view>
      </scroll-view>
      <view v-else
            class="cost-empty">
        <text>{{ costHint }}</text>
      </view>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { monthRange } from "@/utils/warning";
  import { loadDetailData } from "@/utils/detailCache";
  import { PAYMENT_METHOD_OPTIONS } from "@/utils/paymentCycle";
  import {
    addOrUpdateHouseLease,
    getHouseLeaseDetail,
  } from "@/api/collaborativeApproval/houseLeaseManagement";
  // æœˆåº¦è´¹ç”¨äº”个金额列,顺序与表头一致
  const COST_FIELDS = [
    "rent",
    "waterElectricity",
    "property",
    "express",
    "broadband",
  ];
  const formRef = ref();
  const loading = ref(false);
  const leaseId = ref("");
  const costList = ref([]);
  const paymentMethodOptions = PAYMENT_METHOD_OPTIONS;
  // è®°å½•上一次自动灌入的租金,用于判断某月房租是「自动带出」还是「手改过」
  const lastAppliedRent = ref("");
  const form = ref({
    id: undefined,
    office: "",
    userName: "",
    residentStaff: "",
    address: "",
    rentStartDate: "",
    contractStartDate: "",
    contractEndDate: "",
    tenant: "",
    partyA: "",
    phone: "",
    area: "",
    paymentMethod: "",
    deposit: "",
    rent: "",
  });
  const rules = {
    office: [{ required: true, message: "请输入办事处", trigger: "blur" }],
    userName: [{ required: true, message: "请输入使用人", trigger: "blur" }],
    residentStaff: [
      { required: true, message: "请输入驻省人员", trigger: "blur" },
    ],
    address: [{ required: true, message: "请输入地址", trigger: "blur" }],
    rentStartDate: [
      { required: true, message: "请选择起租时间", trigger: "change" },
    ],
    contractStartDate: [
      { required: true, message: "请选择合同起租日", trigger: "change" },
    ],
    contractEndDate: [
      { required: true, message: "请选择合同到期日", trigger: "change" },
    ],
    tenant: [{ required: true, message: "请输入承租人", trigger: "blur" }],
    partyA: [{ required: true, message: "请输入合同甲方", trigger: "blur" }],
    paymentMethod: [
      { required: true, message: "请选择支付方式", trigger: "change" },
    ],
    rent: [{ required: true, message: "请输入租金", trigger: "blur" }],
  };
  const pageTitle = computed(() =>
    leaseId.value ? "编辑房屋租赁" : "新增房屋租赁"
  );
  const costHint = computed(() => {
    if (!form.value.contractStartDate || !form.value.contractEndDate) {
      return "请先选择合同起租日与合同到期日,系统按月份自动生成费用行";
    }
    return `共 ${costList.value.length} ä¸ªæœˆ`;
  });
  const goBack = () => uni.navigateBack();
  const numOrNull = value =>
    value === "" || value === null || value === undefined
      ? null
      : Number.isNaN(Number(value))
        ? null
        : Number(value);
  const rowTotal = row =>
    COST_FIELDS.reduce((sum, key) => sum + (Number(row[key]) || 0), 0).toFixed(2);
  const totals = computed(() => {
    const acc = { total: 0 };
    COST_FIELDS.forEach(key => {
      acc[key] = 0;
    });
    costList.value.forEach(row => {
      COST_FIELDS.forEach(key => {
        acc[key] += Number(row[key]) || 0;
      });
      acc.total += Number(rowTotal(row)) || 0;
    });
    const shown = { total: acc.total.toFixed(2) };
    COST_FIELDS.forEach(key => {
      shown[key] = acc[key].toFixed(2);
    });
    return shown;
  });
  /**
   * æŒ‰åˆåŒèµ·æ­¢æ—¥æœŸé‡å»ºæœˆä»½è¡Œã€‚
   * å·²å­˜åœ¨çš„æœˆä»½æŒ‰ month ä¿ç•™åŽŸå¡«å†™å€¼ï¼Œæ–°å¢žæœˆä»½æˆ¿ç§Ÿé»˜è®¤å–è¡¨å•ç§Ÿé‡‘ã€‚
   */
  const rebuildMonths = () => {
    const months = monthRange(
      form.value.contractStartDate,
      form.value.contractEndDate
    );
    const oldMap = new Map(costList.value.map(row => [row.month, row]));
    costList.value = months.map(month => {
      const old = oldMap.get(month);
      if (old) return old;
      return {
        month,
        rent: form.value.rent ?? "",
        waterElectricity: "",
        property: "",
        express: "",
        broadband: "",
      };
    });
    lastAppliedRent.value = form.value.rent ?? "";
  };
  // è¡¨å•租金变化时,只覆盖「未手改」的月度房租(空值或仍等于上次自动带出的值)
  const applyRentToMonths = () => {
    const next = form.value.rent;
    costList.value.forEach(row => {
      const untouched =
        row.rent === "" ||
        row.rent === null ||
        row.rent === undefined ||
        String(row.rent) === String(lastAppliedRent.value);
      if (untouched) row.rent = next;
    });
    lastAppliedRent.value = next ?? "";
  };
  const loadDetail = async () => {
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "houseLeaseDetail",
        id: leaseId.value,
        fetcher: getHouseLeaseDetail,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "未找到房屋租赁信息", icon: "none" });
      return;
    }
    form.value = {
      id: data.id,
      office: data.office || "",
      userName: data.userName || "",
      residentStaff: data.residentStaff || "",
      address: data.address || "",
      rentStartDate: (data.rentStartDate || "").slice(0, 10),
      contractStartDate: (data.contractStartDate || "").slice(0, 10),
      contractEndDate: (data.contractEndDate || "").slice(0, 10),
      tenant: data.tenant || "",
      partyA: data.partyA || "",
      phone: data.phone || "",
      area: data.area ?? "",
      paymentMethod: data.paymentMethod || "",
      deposit: data.deposit ?? "",
      rent: data.rent ?? "",
    };
    const rows = data.costList || data.costVOs || [];
    costList.value = (Array.isArray(rows) ? rows : []).map(row => ({
      month: row.month,
      rent: row.rent ?? "",
      waterElectricity: row.waterElectricity ?? "",
      property: row.property ?? "",
      express: row.express ?? "",
      broadband: row.broadband ?? "",
    }));
    lastAppliedRent.value = form.value.rent ?? "";
  };
  const buildCostList = () =>
    costList.value.map(row => ({
      ...row,
      rent: numOrNull(row.rent),
      waterElectricity: numOrNull(row.waterElectricity),
      property: numOrNull(row.property),
      express: numOrNull(row.express),
      broadband: numOrNull(row.broadband),
    }));
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    const payload = {
      id: form.value.id,
      office: form.value.office,
      userName: form.value.userName,
      residentStaff: form.value.residentStaff,
      address: form.value.address,
      rentStartDate: form.value.rentStartDate,
      contractStartDate: form.value.contractStartDate,
      contractEndDate: form.value.contractEndDate,
      tenant: form.value.tenant,
      partyA: form.value.partyA,
      phone: form.value.phone,
      area: numOrNull(form.value.area),
      paymentMethod: form.value.paymentMethod,
      deposit: numOrNull(form.value.deposit),
      rent: numOrNull(form.value.rent),
      costList: buildCostList(),
    };
    loading.value = true;
    addOrUpdateHouseLease(payload)
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 1000);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      leaseId.value = options.id;
      form.value.id = options.id;
      loadDetail();
    }
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
  .cost-section {
    margin: 12px 12px 0;
    border-radius: 12px;
    background: #ffffff;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .cost-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 16px 10px;
    background: #f8fbff;
  }
  .cost-header-title {
    font-size: 15px;
    font-weight: 600;
    color: #22324d;
  }
  .cost-header-tip {
    font-size: 12px;
    color: #909399;
  }
  .cost-scroll {
    width: 100%;
    white-space: nowrap;
  }
  .cost-table {
    display: inline-block;
    min-width: 100%;
    padding-bottom: 6px;
  }
  .cost-row {
    display: flex;
    align-items: center;
    border-bottom: 1px solid #f2f4f8;
  }
  .cost-row:last-child {
    border-bottom: none;
  }
  .cost-head {
    background: #fafbfc;
  }
  .cost-head .cost-cell {
    font-size: 12px;
    font-weight: 600;
    color: #606266;
    line-height: 36px;
  }
  .cost-foot {
    background: #f8fbff;
  }
  .cost-foot .cost-cell {
    font-size: 13px;
    font-weight: 600;
    color: #22324d;
    line-height: 42px;
  }
  .cost-cell {
    width: 76px;
    flex: 0 0 76px;
    text-align: center;
    font-size: 13px;
    color: #333;
    line-height: 44px;
  }
  .col-month {
    width: 78px;
    flex: 0 0 78px;
    color: #606266;
  }
  .cost-total {
    color: #2979ff;
    font-weight: 600;
  }
  .cost-empty {
    padding: 24px 16px;
    text-align: center;
    font-size: 13px;
    color: #909399;
    line-height: 1.6;
  }
</style>
src/pages/cooperativeOffice/houseLeaseManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,255 @@
<template>
  <view class="sales-account">
    <PageHeader title="房屋租赁管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.office"
                    placeholder="请输入办事处搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="leaseList.length > 0"
          class="ledger-list">
      <view v-for="item in leaseList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="home"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.office || "-" }}</text>
          </view>
          <up-tag :text="warnOf(item).text || '无到期日'"
                  :type="warnTagType(warnOf(item).level)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">使用人</text>
            <text class="detail-value">{{ item.userName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">驻省人员</text>
            <text class="detail-value">{{ item.residentStaff || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">地址</text>
            <text class="detail-value">{{ item.address || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">承租人</text>
            <text class="detail-value">{{ item.tenant || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">合同甲方</text>
            <text class="detail-value">{{ item.partyA || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">合同起租日</text>
            <text class="detail-value">{{ item.contractStartDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">合同到期日</text>
            <text class="detail-value">{{ item.contractEndDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">租金(元/月)</text>
            <text class="detail-value">{{ formatAmount(item.rent) }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无房屋租赁数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="房屋租赁筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    houseLeaseListPage,
    delHouseLease,
  } from "@/api/collaborativeApproval/houseLeaseManagement";
  import { unwrapList } from "@/utils/list";
  import { getWarnInfo } from "@/utils/warning";
  // åˆåŒåˆ°æœŸæå‰ 30 å¤©é¢„è­¦
  const WARN_DAYS = 30;
  const WARN_STATUS_OPTIONS = [
    { name: "正常", value: "normal" },
    { name: "即将到期", value: "expiring" },
    { name: "已逾期", value: "overdue" },
  ];
  const leaseList = ref([]);
  const showFilter = ref(false);
  const query = reactive({
    office: "",
    userName: "",
    tenant: "",
    partyA: "",
    warnStatus: "",
  });
  const filterFields = [
    { prop: "office", label: "办事处", type: "input", placeholder: "请输入办事处" },
    { prop: "userName", label: "使用人", type: "input", placeholder: "请输入使用人" },
    { prop: "tenant", label: "承租人", type: "input", placeholder: "请输入承租人" },
    { prop: "partyA", label: "合同甲方", type: "input", placeholder: "请输入合同甲方" },
    {
      prop: "warnStatus",
      label: "预警状态",
      type: "select",
      options: WARN_STATUS_OPTIONS,
    },
  ];
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("houseLeaseDetail");
    uni.navigateTo({
      url: "/pages/cooperativeOffice/houseLeaseManagement/edit",
    });
  };
  const goEdit = item => {
    uni.setStorageSync("houseLeaseDetail", item || {});
    uni.navigateTo({
      url: `/pages/cooperativeOffice/houseLeaseManagement/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("houseLeaseDetail", item || {});
    uni.navigateTo({
      url: `/pages/cooperativeOffice/houseLeaseManagement/detail?id=${item.id}`,
    });
  };
  const warnOf = item => getWarnInfo(item?.contractEndDate, WARN_DAYS);
  const warnTagType = level => {
    if (level === "overdue") return "error";
    if (level === "warning") return "warning";
    if (level === "normal") return "success";
    return "info";
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const getList = () => {
    houseLeaseListPage({
      current: -1,
      size: -1,
      office: query.office || undefined,
      userName: query.userName || undefined,
      tenant: query.tenant || undefined,
      partyA: query.partyA || undefined,
      warnStatus: query.warnStatus || undefined,
    })
      .then(res => {
        leaseList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该房屋租赁记录吗?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delHouseLease(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/equipmentManagement/deviceInfo/index.vue
@@ -6,9 +6,18 @@
    <view class="info-card">
      <view class="card-header">
        <text class="card-title">基本信息</text>
        <text class="device-status">正常</text>
        <text
          class="device-status"
          :class="deviceInfo.status === 1 ? 'status-enabled' : 'status-disabled'"
        >
          {{ deviceInfo.status === 1 ? '启用' : '停用' }}
        </text>
      </view>
      <view class="card-content">
        <view class="info-row">
          <text class="label">设备编号:</text>
          <text class="value">{{ deviceInfo.uniqueCode || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">设备名称:</text>
          <text class="value">{{ deviceInfo.deviceName || '--' }}</text>
@@ -18,12 +27,99 @@
          <text class="value">{{ deviceInfo.deviceModel || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">生产厂家:</text>
          <text class="value">{{ deviceInfo.supplierName || '--' }}</text>
          <text class="label">设备品牌:</text>
          <text class="value">{{ deviceInfo.deviceBrand || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">设备类型:</text>
          <text class="value">{{ deviceInfo.type || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">出厂编号:</text>
          <text class="value">{{ deviceInfo.factoryNumber || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">存放位置:</text>
          <text class="value">{{ deviceInfo.storageLocation || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">责任人:</text>
          <text class="value">{{ deviceInfo.responsiblePerson || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">单位:</text>
          <text class="value">{{ deviceInfo.unit || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">建档日期:</text>
          <text class="value">{{ formatDate(deviceInfo.filingDate) }}</text>
        </view>
      </view>
    </view>
    <!-- é‡‡è´­ä¿¡æ¯ -->
    <view class="info-card">
      <view class="card-header">
        <text class="card-title">采购信息</text>
      </view>
      <view class="card-content">
        <view class="info-row">
          <text class="label">供应商:</text>
          <text class="value">{{ deviceInfo.supplierName || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">制造厂商:</text>
          <text class="value">{{ deviceInfo.manufacturer || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">购置日期:</text>
          <text class="value">{{ formatDate(deviceInfo.purchaseDate) }}</text>
        </view>
        <view class="info-row">
          <text class="label">资产原值:</text>
          <text class="value">{{ deviceInfo.originalAssetValue ?? '--' }}</text>
        </view>
      </view>
    </view>
    <!-- æŠ€æœ¯å‚æ•° -->
    <view class="info-card">
      <view class="card-header">
        <text class="card-title">技术参数</text>
      </view>
      <view class="card-content">
        <view class="info-row">
          <text class="label">主要技术参数:</text>
          <text class="value">{{ deviceInfo.technicalParameters || '--' }}</text>
        </view>
        <view class="info-row">
          <text class="label">主要用途:</text>
          <text class="value">{{ deviceInfo.mainPurpose || '--' }}</text>
        </view>
      </view>
    </view>
    <!-- è¿è¡Œç®¡ç† -->
    <view class="info-card">
      <view class="card-header">
        <text class="card-title">运行管理</text>
      </view>
      <view class="card-content">
        <view class="info-row">
          <text class="label">启用日期:</text>
          <text class="value">{{ formatDate(deviceInfo.activationDate) }}</text>
        </view>
        <view class="info-row">
          <text class="label">预计运行时间:</text>
          <text class="value">{{ formatDate(deviceInfo.planRuntimeTime) }}</text>
        </view>
        <view class="info-row">
          <text class="label">启用折旧:</text>
          <text class="value">{{ deviceInfo.isDepr === 1 ? '是' : '否' }}</text>
        </view>
        <view v-if="deviceInfo.isDepr === 1" class="info-row">
          <text class="label">每年折旧金额:</text>
          <text class="value">{{ deviceInfo.annualDepreciationAmount ?? '--' }}</text>
        </view>
      </view>
    </view>
@@ -49,25 +145,94 @@
        </view>
      </view>
    </view>
    <!-- æŠ¥ä¿®è®°å½• -->
    <view class="info-card">
      <view class="card-header">
        <text class="card-title">报修记录</text>
      </view>
      <view class="card-content">
        <view v-if="repairRecords.length === 0" class="empty-tip">暂无报修记录</view>
        <view v-else class="change-record" v-for="(item, index) in repairRecords" :key="index">
          <view class="record-header">
            <text class="record-time">{{ formatDate(item.repairTime) }}</text>
            <text class="record-status" :class="item.status === 1 ? 'status-approved' : 'status-rejected'">
              {{ item.status === 1 ? '完结' : '待维修' }}
            </text>
          </view>
          <view class="record-reason">报修人:{{ item.repairName || '--' }} 维修人:{{ item.maintenanceName || '--' }}</view>
          <view class="record-reason">维修项目:{{ item.machineryCategory || '--' }}</view>
          <view class="record-reason">故障现象:{{ item.remark || '--' }}</view>
          <view class="record-reason">维修结果:{{ item.maintenanceResult || '--' }}</view>
        </view>
      </view>
    </view>
    <!-- ä¿å…»è®°å½• -->
    <view class="info-card">
      <view class="card-header">
        <text class="card-title">保养记录</text>
      </view>
      <view class="card-content">
        <view v-if="upkeepRecords.length === 0" class="empty-tip">暂无保养记录</view>
        <view v-else class="change-record" v-for="(item, index) in upkeepRecords" :key="index">
          <view class="record-header">
            <text class="record-time">{{ formatDate(item.maintenancePlanTime) }}</text>
            <text class="record-status" :class="item.status === 1 ? 'status-approved' : 'status-rejected'">
              {{ item.status === 1 ? '完结' : '待保养' }}
            </text>
          </view>
          <view class="record-reason">保养项目:{{ item.machineryCategory || '--' }}</view>
          <view class="record-reason">保养人:{{ item.maintenancePerson || '--' }} 录入人:{{ item.createUserName || '--' }}</view>
          <view class="record-reason">实际保养日期:{{ formatDate(item.maintenanceActuallyTime) }}</view>
        </view>
      </view>
    </view>
  </view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import PageHeader from '@/components/PageHeader.vue'
import { getDeviceInfo } from '@/api/equipmentManagement/deviceInfo'
import { listStatusChangeRecord } from '@/api/device/statusChangeRecord'
import { getRepairPage } from '@/api/equipmentManagement/repair'
import { getUpkeepPage } from '@/api/equipmentManagement/upkeep'
import { unwrapList } from '@/utils/list'
const deviceInfo = reactive({
  uniqueCode: '',
  deviceName: '',
  deviceModel: '',
  deviceBrand: '',
  type: '',
  factoryNumber: '',
  storageLocation: '',
  responsiblePerson: '',
  filingDate: '',
  supplierName: '',
  manufacturer: '',
  purchaseDate: '',
  originalAssetValue: null,
  unit: '',
  technicalParameters: '',
  mainPurpose: '',
  status: null,
  activationDate: '',
  planRuntimeTime: '',
  isDepr: 2,
  annualDepreciationAmount: null,
})
const changeRecords = ref([])
const recordsLoading = ref(false)
const repairRecords = ref([])
const upkeepRecords = ref([])
// åŽç«¯æ—¥æœŸå¤šå¸¦æ—¶åˆ†ç§’,这里统一只显示到日
const formatDate = (d) => (d ? dayjs(d).format('YYYY-MM-DD') : '--')
const statusText = (s) => (s == 1 ? '启用' : '停用')
@@ -112,6 +277,18 @@
  uni.navigateBack()
}
// æŠ¥ä¿®/保养列表接口只提供 deviceName è¿‡æ»¤ï¼ˆä¸Žç®¡ç†ç«¯ä¸€è‡´ï¼Œæ—  deviceId ç»´åº¦ï¼‰
const fetchDeviceHistory = async (deviceName) => {
  if (!deviceName) return
  const params = { current: -1, size: -1, deviceName }
  const [repair, upkeep] = await Promise.all([
    getRepairPage(params).catch(() => null),
    getUpkeepPage(params).catch(() => null),
  ])
  repairRecords.value = unwrapList(repair)
  upkeepRecords.value = unwrapList(upkeep)
}
onLoad((options) => {
  const deviceId = options?.deviceId || ''
  if (!deviceId) {
@@ -119,6 +296,9 @@
    return
  }
  fetchDeviceInfo(deviceId)
    // å±¥åŽ†é‡Œçš„æŠ¥ä¿®/保养记录要等基本信息拿到设备名称后才能查
    .then(() => fetchDeviceHistory(deviceInfo.deviceName))
    .catch(() => {})
  fetchChangeRecords(deviceId)
})
</script>
@@ -161,6 +341,14 @@
  background: #52c41a;
}
.status-enabled {
  background: #52c41a;
}
.status-disabled {
  background: #bfbfbf;
}
.card-content {
  padding: 16px 20px;
}
@@ -176,8 +364,10 @@
}
.label {
  width: 88px;
  /* ã€Œä¸»è¦æŠ€æœ¯å‚数:」比原来最长的标签更宽,改成按内容撑开,避免换行或截断 */
  min-width: 88px;
  flex-shrink: 0;
  white-space: nowrap;
  color: #666666;
  font-size: 14px;
}
src/pages/equipmentManagement/ledger/detail.vue
@@ -7,6 +7,21 @@
        <u-form @submit="sendForm" ref="formRef" :model="form" :rules="formRules" label-width="110">
            <!-- åŸºæœ¬ä¿¡æ¯ -->
            <u-cell-group title="基本信息">
                <u-form-item label="设备编号" prop="uniqueCode" border-bottom>
                    <u-input
                        v-model="form.uniqueCode"
                        :disabled="form.autoGenerateUniqueCode || operationType === 'edit'"
                        :placeholder="form.autoGenerateUniqueCode ? '保存后自动生成' : '请输入设备编号'"
                        clearable
                    />
                </u-form-item>
                <u-form-item v-if="operationType !== 'edit'" label="自动生成编号" prop="autoGenerateUniqueCode" border-bottom>
                    <u-switch
                        v-model="form.autoGenerateUniqueCode"
                        :activeValue="true"
                        :inactiveValue="false"
                    />
                </u-form-item>
                <u-form-item label="设备名称" prop="deviceName" required border-bottom>
                    <u-input
                        v-model="form.deviceName"
@@ -22,13 +37,29 @@
                        clearable
                    />
                </u-form-item>
                <!-- <u-form-item label="设备品牌" prop="deviceBrand" required border-bottom>
                <u-form-item label="设备品牌" prop="deviceBrand" border-bottom>
                    <u-input
                        v-model="form.deviceBrand"
                        placeholder="请输入设备品牌"
                        clearable
                    />
                </u-form-item> -->
                </u-form-item>
                <u-form-item label="设备类型" prop="type" required border-bottom>
                    <FormPicker
                        v-model="form.type"
                        type="select"
                        title="选择设备类型"
                        :options="deviceTypeOptions"
                        placeholder="请选择设备类型"
                    />
                </u-form-item>
                <u-form-item label="出厂编号" prop="factoryNumber" border-bottom>
                    <u-input
                        v-model="form.factoryNumber"
                        placeholder="请输入出厂编号"
                        clearable
                    />
                </u-form-item>
                <u-form-item label="供应商" prop="supplierName" required border-bottom>
                    <u-input
                        v-model="form.supplierName"
@@ -36,13 +67,27 @@
                        clearable
                    />
                </u-form-item>
                <!-- <u-form-item label="存放位置" prop="storageLocation" required border-bottom>
                <u-form-item label="存放位置" prop="storageLocation" border-bottom>
                    <u-input
                        v-model="form.storageLocation"
                        placeholder="请输入存放位置"
                        clearable
                    />
                </u-form-item> -->
                </u-form-item>
                <u-form-item label="责任人" prop="responsiblePerson" border-bottom>
                    <u-input
                        v-model="form.responsiblePerson"
                        placeholder="请输入责任人"
                        clearable
                    />
                </u-form-item>
                <u-form-item label="建档日期" prop="filingDate" border-bottom>
                    <FormPicker
                        v-model="form.filingDate"
                        type="date"
                        placeholder="请选择建档日期"
                    />
                </u-form-item>
                <u-form-item label="单位" prop="unit" required border-bottom>
                    <u-input
                        v-model="form.unit"
@@ -50,13 +95,6 @@
                        clearable
                    />
                </u-form-item>
                <!-- <u-form-item label="启用折旧" prop="enableDepreciation" required border-bottom>
                    <u-switch
                        v-model="form.enableDepreciation"
                        :active-value="true"
                        :inactive-value="false"
                    />
                </u-form-item> -->
                <u-form-item label="数量" prop="number" required border-bottom>
                    <u-input
                        v-model="form.number"
@@ -123,6 +161,98 @@
                </u-form-item>
            </u-cell-group>
        
            <u-cell-group title="采购信息">
                <u-form-item label="制造厂商" prop="manufacturer" border-bottom>
                    <u-input
                        v-model="form.manufacturer"
                        placeholder="请输入制造厂商"
                        clearable
                    />
                </u-form-item>
                <u-form-item label="购置日期" prop="purchaseDate" border-bottom>
                    <FormPicker
                        v-model="form.purchaseDate"
                        type="date"
                        placeholder="请选择购置日期"
                    />
                </u-form-item>
                <u-form-item label="资产原值" prop="originalAssetValue" border-bottom>
                    <u-input
                        v-model="form.originalAssetValue"
                        type="number"
                        placeholder="请输入资产原值"
                        clearable
                    />
                </u-form-item>
            </u-cell-group>
            <u-cell-group title="技术参数">
                <u-form-item label="主要技术参数" prop="technicalParameters" border-bottom>
                    <up-textarea
                        v-model="form.technicalParameters"
                        placeholder="请输入主要技术参数"
                        :maxlength="500"
                        count
                        auto-height
                    />
                </u-form-item>
                <u-form-item label="主要用途" prop="mainPurpose" border-bottom>
                    <up-textarea
                        v-model="form.mainPurpose"
                        placeholder="请输入主要用途"
                        :maxlength="500"
                        count
                        auto-height
                    />
                </u-form-item>
            </u-cell-group>
            <u-cell-group title="运行管理">
                <u-form-item label="设备状态" prop="status" required border-bottom>
                    <FormPicker
                        v-model="form.status"
                        type="select"
                        title="选择设备状态"
                        :options="statusOptions"
                        placeholder="请选择设备状态"
                    />
                </u-form-item>
                <u-form-item label="启用日期" prop="activationDate" border-bottom>
                    <FormPicker
                        v-model="form.activationDate"
                        type="date"
                        placeholder="请选择启用日期"
                    />
                </u-form-item>
                <u-form-item label="预计运行时间" prop="planRuntimeTime" border-bottom>
                    <FormPicker
                        v-model="form.planRuntimeTime"
                        type="date"
                        placeholder="请选择预计运行时间"
                    />
                </u-form-item>
                <u-form-item label="启用折旧" prop="isDepr" border-bottom>
                    <u-switch
                        v-model="form.isDepr"
                        :activeValue="1"
                        :inactiveValue="2"
                    />
                </u-form-item>
                <u-form-item v-if="form.isDepr === 1" label="每年折旧金额" prop="annualDepreciationAmount" border-bottom>
                    <u-input
                        v-model="form.annualDepreciationAmount"
                        type="number"
                        placeholder="请输入每年折旧金额"
                        clearable
                    />
                </u-form-item>
                <u-form-item label="设备图片" border-bottom>
                    <CommonUpload
                        v-model="imageFiles"
                        :maxCount="20"
                        accept="image"
                    />
                </u-form-item>
            </u-cell-group>
        <!-- æäº¤æŒ‰é’® -->
        <view class="footer-btns">
            <u-button class="cancel-btn" @click="goBack">取消</u-button>
@@ -148,7 +278,10 @@
import { ref, computed, onMounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import PageHeader from '@/components/PageHeader.vue';
import FormPicker from '@/components/FormPicker.vue';
import CommonUpload from '@/components/CommonUpload.vue';
import { getLedgerById, addLedger, editLedger } from '@/api/equipmentManagement/ledger';
import { toFileList } from '@/utils/file';
import dayjs from "dayjs";
import {
    calculateTaxIncludeTotalPrice,
@@ -182,9 +315,8 @@
const formRules = {
    deviceName: [{ required: true, trigger: "blur", message: "请输入" }],
    deviceModel: [{ required: true, trigger: "blur", message: "请输入" }],
    deviceBrand: [{ required: true, trigger: "blur", message: "请输入" }],
    type: [{ required: true, trigger: "change", message: "请选择设备类型" }],
    supplierName: [{ required: true, trigger: "blur", message: "请输入" }],
    storageLocation: [{ required: true, trigger: "blur", message: "请输入" }],
    unit: [{ required: true, trigger: "blur", message: "请输入" }],
    // æ•°å­—类型字段需要特殊处理,确保有数值时不会触发必填校验
    number: [{
@@ -227,23 +359,68 @@
        }
    }],
    createTime: [{ required: true, trigger: "change", message: "请选择" }],
    status: [{ required: true, trigger: "change", message: "请选择设备状态" }],
    planRuntimeTime: [{ required: true, trigger: "change", message: "请选择预计运行时间" }],
};
// è®¾å¤‡ç±»åž‹å›ºå®šé€‰é¡¹ï¼ˆä¸Žç®¡ç†ç«¯ä¸€è‡´ï¼Œæ— æ³•命中的值会由 FormPicker çš„选项展示兜底)
const deviceTypeOptions = ref([
    { name: '生产设备', value: '生产设备' },
    { name: '办公设备', value: '办公设备' },
    { name: '检测设备', value: '检测设备' },
    { name: '运输设备', value: '运输设备' },
    { name: '其他设备', value: '其他设备' },
]);
const statusOptions = ref([
    { name: '停用', value: 0 },
    { name: '启用', value: 1 },
]);
// ä½¿ç”¨ ref å£°æ˜Žè¡¨å•数据
const form = ref({
    // åŸºæœ¬ä¿¡æ¯
    autoGenerateUniqueCode: true, // è‡ªåŠ¨ç”Ÿæˆç¼–å·
    uniqueCode: undefined, // è®¾å¤‡ç¼–号
    deviceName: undefined, // è®¾å¤‡åç§°
    deviceModel: undefined, // è§„格型号
    deviceBrand: undefined, // è®¾å¤‡å“ç‰Œ
    supplierName: undefined, // ä¾›åº”商
    type: undefined, // è®¾å¤‡ç±»åž‹
    factoryNumber: undefined, // å‡ºåŽ‚ç¼–å·
    storageLocation: undefined, // å­˜æ”¾ä½ç½®
    responsiblePerson: undefined, // è´£ä»»äºº
    filingDate: dayjs().format("YYYY-MM-DD"), // å»ºæ¡£æ—¥æœŸ
    // é‡‡è´­ä¿¡æ¯
    supplierName: undefined, // ä¾›åº”商
    manufacturer: undefined, // åˆ¶é€ åނ商
    purchaseDate: undefined, // è´­ç½®æ—¥æœŸ
    originalAssetValue: undefined, // èµ„产原值
    unit: undefined, // å•位
    enableDepreciation: false, // å¯ç”¨æŠ˜æ—§
    number: undefined, // æ•°é‡
    number: 1, // æ•°é‡
    taxIncludingPriceUnit: undefined, // å«ç¨Žå•ä»·
    taxIncludingPriceTotal: undefined, // å«ç¨Žæ€»ä»·
    taxRate: undefined, // ç¨Žçއ
    unTaxIncludingPriceTotal: undefined, // ä¸å«ç¨Žæ€»ä»·
    // æŠ€æœ¯å‚æ•°
    technicalParameters: undefined, // ä¸»è¦æŠ€æœ¯å‚æ•°
    mainPurpose: undefined, // ä¸»è¦ç”¨é€”
    // è¿è¡Œç®¡ç†
    status: 1, // è®¾å¤‡çŠ¶æ€ 0停用 1启用
    activationDate: undefined, // å¯ç”¨æ—¥æœŸ
    planRuntimeTime: dayjs().format("YYYY-MM-DD"), // é¢„计运行时间
    isDepr: 2, // æ˜¯å¦å¯ç”¨æŠ˜æ—§ 1是 2否
    annualDepreciationAmount: undefined, // æ¯å¹´æŠ˜æ—§é‡‘额
    storageBlobDTOs: undefined, // è®¾å¤‡å›¾ç‰‡ï¼ˆæäº¤ï¼‰
    storageBlobVOs: undefined, // è®¾å¤‡å›¾ç‰‡ï¼ˆå›žæ˜¾ï¼‰
    createTime: dayjs().format("YYYY-MM-DD"), // å½•入日期
});
// è®¾å¤‡å›¾ç‰‡ï¼šCommonUpload ç»‘ modelValue,改动时同时写回提交/回显两个字段
const imageFiles = computed({
    get: () => form.value.storageBlobVOs || [],
    set: val => {
        form.value.storageBlobDTOs = val;
        form.value.storageBlobVOs = val;
    },
});
@@ -258,18 +435,51 @@
        console.log(data);
        
        if (code == 200) {
            // åŸºæœ¬ä¿¡æ¯
            form.value.autoGenerateUniqueCode = false;
            form.value.uniqueCode = data.uniqueCode;
            form.value.deviceName = data.deviceName;
            form.value.deviceModel = data.deviceModel;
            form.value.deviceBrand = data.deviceBrand || '';
            form.value.supplierName = data.supplierName;
            form.value.type = data.type;
            form.value.factoryNumber = data.factoryNumber;
            form.value.storageLocation = data.storageLocation || '';
            form.value.responsiblePerson = data.responsiblePerson;
            form.value.filingDate = data.filingDate
                ? dayjs(data.filingDate).format("YYYY-MM-DD")
                : undefined;
            // é‡‡è´­ä¿¡æ¯
            form.value.supplierName = data.supplierName;
            form.value.manufacturer = data.manufacturer;
            form.value.purchaseDate = data.purchaseDate
                ? dayjs(data.purchaseDate).format("YYYY-MM-DD")
                : undefined;
            form.value.originalAssetValue = data.originalAssetValue;
            form.value.unit = data.unit;
            form.value.enableDepreciation = !!data.enableDepreciation;
            form.value.number = data.number;
            form.value.taxIncludingPriceUnit = data.taxIncludingPriceUnit;
            form.value.taxIncludingPriceTotal = data.taxIncludingPriceTotal;
            form.value.taxRate = data.taxRate;
            form.value.unTaxIncludingPriceTotal = data.unTaxIncludingPriceTotal;
            // æŠ€æœ¯å‚æ•°
            form.value.technicalParameters = data.technicalParameters;
            form.value.mainPurpose = data.mainPurpose;
            // è¿è¡Œç®¡ç†
            form.value.status = data.status != null ? data.status : 1;
            form.value.activationDate = data.activationDate
                ? dayjs(data.activationDate).format("YYYY-MM-DD")
                : undefined;
            form.value.planRuntimeTime = data.planRuntimeTime
                ? dayjs(data.planRuntimeTime).format("YYYY-MM-DD")
                : undefined;
            form.value.isDepr = data.isDepr != null ? data.isDepr : 2;
            form.value.annualDepreciationAmount = data.annualDepreciationAmount;
            form.value.storageBlobVOs = toFileList(data.storageBlobVOs);
            form.value.storageBlobDTOs = form.value.storageBlobVOs;
            form.value.createTime = data.createTime;
            
            // æ•°æ®åŠ è½½å®ŒæˆåŽï¼Œé‡ç½®è¡¨å•éªŒè¯çŠ¶æ€
@@ -284,12 +494,11 @@
// æ•°å­¦è®¡ç®—
const mathNum = () => {
    // åªæœ‰åœ¨æ–°å¢žæ¨¡å¼æˆ–者字段确实为空时才显示提示
    if (operationType.value !== 'edit' || (form.value.taxIncludingPriceUnit === undefined || form.value.taxIncludingPriceUnit === '')) {
    if (!form.value.taxIncludingPriceUnit) {
        showToast("请输入单价");
        return;
    }
    if (operationType.value !== 'edit' || (form.value.number === undefined || form.value.number === '')) {
    if (!form.value.number) {
        showToast("请输入数量");
        return;
    }
@@ -318,18 +527,33 @@
        let errorMessage = '';
        
        // æ£€æŸ¥æ–‡æœ¬ç±»åž‹å¿…填字段
        if (!form.value.deviceName) {
        if (!form.value.autoGenerateUniqueCode && !form.value.uniqueCode) {
            isValid = false;
            errorMessage = '请输入设备编号';
        } else if (!form.value.deviceName) {
            isValid = false;
            errorMessage = '请输入设备名称';
        } else if (!form.value.deviceModel) {
            isValid = false;
            errorMessage = '请输入规格型号';
        } else if (!form.value.type) {
            isValid = false;
            errorMessage = '请选择设备类型';
        } else if (!form.value.supplierName) {
            isValid = false;
            errorMessage = '请输入供应商';
        } else if (!form.value.unit) {
            isValid = false;
            errorMessage = '请输入单位';
        } else if (form.value.status === undefined || form.value.status === null || form.value.status === '') {
            isValid = false;
            errorMessage = '请选择设备状态';
        } else if (form.value.isDepr === 1 && !form.value.annualDepreciationAmount) {
            isValid = false;
            errorMessage = '请输入每年折旧金额';
        } else if (!form.value.planRuntimeTime) {
            isValid = false;
            errorMessage = '请选择预计运行时间';
        }
        
        // æ£€æŸ¥æ•°å­—类型必填字段
src/pages/equipmentManagement/ledger/index.vue
@@ -37,8 +37,29 @@
          
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">设备编号</text>
              <text class="detail-value">{{ item.uniqueCode || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">规格型号</text>
              <text class="detail-value">{{ item.deviceModel || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">设备类型</text>
              <text class="detail-value">{{ item.type || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">责任人</text>
              <text class="detail-value">{{ item.responsiblePerson || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">设备状态</text>
              <text
                class="detail-value"
                :class="item.status === 1 ? 'status-enabled' : 'status-disabled'"
              >
                {{ item.status === 1 ? '启用' : '停用' }}
              </text>
            </view>
            <view class="detail-row">
              <text class="detail-label">供应商</text>
@@ -221,4 +242,12 @@
@import '@/styles/sales-common.scss';
// è®¾å¤‡å°è´¦ç‰¹æœ‰æ ·å¼ï¼ˆæ‰€æœ‰æ ·å¼éƒ½å·²åŒ…含在公共样式中)
.status-enabled {
  color: #67c23a;
}
.status-disabled {
  color: #f56c6c;
}
</style>
src/pages/inventoryManagement/fuelCard/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,273 @@
<template>
  <view class="fuel-card-detail">
    <PageHeader title="加油卡详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <text class="hero-title">{{ detail.cardNumber || "-" }}</text>
        <up-tag :text="statusLabel(detail.status)"
                :type="detail.status === 'CANCELLED' ? 'error' : 'success'"
                size="mini"
                shape="circle" />
      </view>
      <u-cell-group title="加油卡信息"
                    class="detail-section">
        <u-cell title="加油卡号"
                :value="detail.cardNumber || '-'" />
        <u-cell title="所属部门"
                :value="detail.deptName || '-'" />
        <u-cell title="发卡单位"
                :value="detail.issuer || '-'" />
        <u-cell title="期初余额"
                :value="formatAmount(detail.initialBalance)" />
        <u-cell title="当前余额"
                :value="formatAmount(detail.currentBalance)" />
        <u-cell title="状态"
                :value="statusLabel(detail.status)" />
        <u-cell title="备注"
                :value="detail.remark || '-'" />
      </u-cell-group>
      <u-cell-group title="充值记录"
                    class="detail-section">
        <view class="section-tools">
          <up-button type="primary"
                     size="small"
                     text="充值管理"
                     @click="goRecharge" />
        </view>
        <view v-if="rechargeList.length === 0"
              class="empty-text">
          <text>暂无充值记录</text>
        </view>
        <view v-else
              class="recharge-list">
          <view v-for="row in rechargeList"
                :key="row.id"
                class="recharge-item">
            <view class="recharge-main">
              <text class="recharge-amount">+{{ Number(row.rechargeAmount || 0).toFixed(2) }}</text>
              <text class="recharge-date">{{ row.rechargeDate || "-" }}</text>
            </view>
            <view class="recharge-meta">
              <text v-if="row.createUserName"
                    class="recharge-user">{{ row.createUserName }}</text>
              <text v-if="row.remark"
                    class="recharge-remark">{{ row.remark }}</text>
            </view>
          </view>
        </view>
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
  </view>
</template>
<script setup>
  import { ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { unwrapList } from "@/utils/list";
  import {
    listFuelCardPage,
    listRecharge,
  } from "@/api/inventoryManagement/vehicle";
  const loading = ref(true);
  const cardId = ref("");
  const detail = ref({});
  const rechargeList = ref([]);
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("fuelCardDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelCard/edit?id=${cardId.value}`,
    });
  };
  const goRecharge = () => {
    uni.setStorageSync("fuelCardDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelCard/recharge?id=${cardId.value}`,
    });
  };
  const statusLabel = status => (status === "CANCELLED" ? "注销" : "正常");
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  // åŠ æ²¹å¡æ²¡æœ‰å•æ¡è¯¦æƒ…æŽ¥å£ï¼Œä»Žåˆ—è¡¨é‡ŒæŒ‰ id æž
  const loadCard = async () => {
    const cached = uni.getStorageSync("fuelCardDetail");
    if (cached && String(cached.id) === String(cardId.value)) {
      detail.value = cached;
    }
    try {
      const res = await listFuelCardPage({ current: -1, size: -1 });
      const found = unwrapList(res).find(
        item => String(item.id) === String(cardId.value)
      );
      if (found) detail.value = found;
    } catch (error) {
      // æ‹‰ä¸åˆ°å°±æ²¿ç”¨ç¼“å­˜
    }
  };
  const loadRecharges = () => {
    listRecharge({ current: -1, size: -1, cardId: cardId.value })
      .then(res => {
        rechargeList.value = unwrapList(res);
      })
      .catch(() => {
        rechargeList.value = [];
      });
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      await loadCard();
      loadRecharges();
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    cardId.value = options?.id || "";
    if (!cardId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .fuel-card-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .section-tools {
    display: flex;
    justify-content: flex-end;
    padding: 12px 12px 0;
  }
  .empty-text {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .recharge-list {
    padding: 6px 12px 12px;
  }
  .recharge-item {
    padding: 12px 0;
    border-bottom: 1px solid #f2f4f8;
  }
  .recharge-item:last-child {
    border-bottom: none;
  }
  .recharge-main {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .recharge-amount {
    font-size: 15px;
    font-weight: 600;
    color: #19be6b;
  }
  .recharge-date {
    font-size: 12px;
    color: #909399;
  }
  .recharge-meta {
    display: flex;
    gap: 12px;
    margin-top: 4px;
  }
  .recharge-user,
  .recharge-remark {
    font-size: 12px;
    color: #909399;
    word-break: break-all;
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/inventoryManagement/fuelCard/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,223 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="110"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="加油卡信息"
                      class="form-section">
          <up-form-item label="加油卡号"
                        prop="cardNumber"
                        required>
            <up-input v-model="form.cardNumber"
                      placeholder="请输入加油卡号"
                      clearable />
          </up-form-item>
          <up-form-item label="所属部门"
                        prop="deptId"
                        required>
            <FormPicker v-model="form.deptId"
                        type="select"
                        title="选择所属部门"
                        :options="deptOptions"
                        placeholder="请选择所属部门"
                        @change="onDeptChange" />
          </up-form-item>
          <up-form-item label="发卡单位"
                        prop="issuer"
                        required>
            <up-input v-model="form.issuer"
                      placeholder="请输入发卡单位"
                      clearable />
          </up-form-item>
          <!-- æœŸåˆä½™é¢åªåœ¨æ–°å¢žæ—¶å¯å¡«ï¼Œç¼–辑时余额由充值/加油记录维护 -->
          <up-form-item v-if="!cardId"
                        label="期初余额"
                        prop="initialBalance">
            <up-input v-model="form.initialBalance"
                      type="number"
                      placeholder="请输入期初余额"
                      clearable />
          </up-form-item>
          <up-form-item label="状态"
                        prop="status"
                        required>
            <FormPicker v-model="form.status"
                        type="select"
                        title="选择状态"
                        :options="STATUS_OPTIONS"
                        placeholder="请选择状态" />
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-textarea v-model="form.remark"
                         placeholder="请输入备注"
                         auto-height />
          </up-form-item>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, onMounted, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import FooterButtons from "@/components/FooterButtons.vue";
  import PageHeader from "@/components/PageHeader.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess";
  import { loadDetailData } from "@/utils/detailCache";
  import {
    addFuelCard,
    listFuelCardPage,
    updateFuelCard,
  } from "@/api/inventoryManagement/vehicle";
  const STATUS_OPTIONS = [
    { name: "正常", value: "ACTIVE" },
    { name: "注销", value: "CANCELLED" },
  ];
  const formRef = ref();
  const loading = ref(false);
  const cardId = ref("");
  const deptOptions = ref([]);
  const form = ref({
    id: undefined,
    cardNumber: "",
    deptId: "",
    deptName: "",
    issuer: "",
    initialBalance: 0,
    status: "ACTIVE",
    remark: "",
  });
  const rules = {
    cardNumber: [{ required: true, message: "请输入加油卡号", trigger: "blur" }],
    deptId: [{ required: true, message: "请选择所属部门", trigger: "change" }],
    issuer: [{ required: true, message: "请输入发卡单位", trigger: "blur" }],
    status: [{ required: true, message: "请选择状态", trigger: "change" }],
  };
  const pageTitle = computed(() => (cardId.value ? "编辑加油卡" : "新增加油卡"));
  const goBack = () => uni.navigateBack();
  const onDeptChange = (value, item) => {
    form.value.deptName = item?.name || "";
  };
  const loadDepts = async () => {
    try {
      const res = await getDept();
      deptOptions.value = (res?.data || []).map(item => ({
        name: item.deptName,
        value: item.deptId,
      }));
    } catch (error) {
      deptOptions.value = [];
    }
  };
  // åŠ æ²¹å¡æ²¡æœ‰å•æ¡è¯¦æƒ…æŽ¥å£ï¼Œä»Žåˆ—è¡¨é‡ŒæŒ‰ id æž
  const fetchCard = async id => {
    const res = await listFuelCardPage({ current: -1, size: -1 });
    const rows = res?.data?.records || res?.records || res?.data || [];
    const found = (Array.isArray(rows) ? rows : []).find(
      item => String(item.id) === String(id)
    );
    return { data: found };
  };
  const loadDetail = async () => {
    if (!cardId.value) return;
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "fuelCardDetail",
        id: cardId.value,
        fetcher: fetchCard,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "获取加油卡详情失败", icon: "none" });
      return;
    }
    form.value = { ...form.value, ...data, id: data.id };
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    loading.value = true;
    const action = cardId.value ? updateFuelCard : addFuelCard;
    action({ ...form.value, id: cardId.value || undefined })
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 300);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      cardId.value = options.id;
      form.value.id = options.id;
    }
  });
  onMounted(async () => {
    await loadDepts();
    if (cardId.value) await loadDetail();
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/inventoryManagement/fuelCard/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,282 @@
<template>
  <view class="sales-account">
    <PageHeader title="加油卡管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.cardNumber"
                    placeholder="请输入加油卡号搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="cardList.length > 0"
          class="ledger-list">
      <view v-for="item in cardList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="rmb-circle"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.cardNumber || "-" }}</text>
          </view>
          <up-tag :text="statusLabel(item.status)"
                  :type="item.status === 'CANCELLED' ? 'error' : 'success'"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">所属部门</text>
            <text class="detail-value">{{ item.deptName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发卡单位</text>
            <text class="detail-value">{{ item.issuer || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">期初余额</text>
            <text class="detail-value">{{ formatAmount(item.initialBalance) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">当前余额</text>
            <text class="detail-value highlight">{{ formatAmount(item.currentBalance) }}</text>
          </view>
          <view class="detail-row"
                v-if="item.remark">
            <text class="detail-label">备注</text>
            <text class="detail-value">{{ item.remark }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="success"
                     @click="goRecharge(item)">充值管理</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button v-if="item.status !== 'CANCELLED'"
                     class="action-btn"
                     size="small"
                     type="warning"
                     plain
                     @click="handleCancelCard(item)">注销</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无加油卡数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="加油卡筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess";
  import { unwrapList } from "@/utils/list";
  import {
    delFuelCard,
    listFuelCardPage,
    updateFuelCard,
  } from "@/api/inventoryManagement/vehicle";
  const cardList = ref([]);
  const showFilter = ref(false);
  const deptOptions = ref([]);
  const query = reactive({
    cardNumber: "",
    deptId: "",
  });
  const filterFields = computed(() => [
    {
      prop: "cardNumber",
      label: "加油卡号",
      type: "input",
      placeholder: "请输入加油卡号",
    },
    {
      prop: "deptId",
      label: "所属部门",
      type: "select",
      options: deptOptions.value,
    },
  ]);
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("fuelCardDetail");
    uni.navigateTo({ url: "/pages/inventoryManagement/fuelCard/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("fuelCardDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelCard/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("fuelCardDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelCard/detail?id=${item.id}`,
    });
  };
  const goRecharge = item => {
    uni.setStorageSync("fuelCardDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelCard/recharge?id=${item.id}`,
    });
  };
  const statusLabel = status => (status === "CANCELLED" ? "注销" : "正常");
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const getList = () => {
    listFuelCardPage({
      current: -1,
      size: -1,
      cardNumber: query.cardNumber || undefined,
      deptId: query.deptId || undefined,
    })
      .then(res => {
        cardList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleCancelCard = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "注销确认",
      content: `确认注销加油卡「${item.cardNumber || ""}」吗?注销后该卡不能用于新增加油记录,余额将保留。`,
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        updateFuelCard({ id: item.id, status: "CANCELLED" })
          .then(() => {
            uni.showToast({ title: "注销成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "注销失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: `确认删除加油卡「${item.cardNumber || ""}」吗?`,
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delFuelCard(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  let deptsLoaded = false;
  const loadDepts = () => {
    if (deptsLoaded) return;
    deptsLoaded = true;
    getDept()
      .then(res => {
        deptOptions.value = (res?.data || []).map(item => ({
          name: item.deptName,
          value: item.deptId,
        }));
      })
      .catch(() => {
        deptsLoaded = false;
      });
  };
  onShow(() => {
    getList();
    loadDepts();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/inventoryManagement/fuelCard/recharge.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,401 @@
<template>
  <view class="recharge-page">
    <PageHeader title="充值管理"
                @back="goBack" />
    <view class="hero">
      <text class="hero-title">{{ card.cardNumber || "-" }}</text>
      <view class="hero-balance">
        <text class="hero-label">当前余额</text>
        <text class="hero-value">{{ formatAmount(currentBalance) }}</text>
      </view>
    </view>
    <view class="section">
      <view class="section-header">
        <text class="section-title">充值记录</text>
        <up-button type="primary"
                   size="small"
                   text="新增充值"
                   @click="openForm" />
      </view>
      <view v-if="rechargeList.length === 0"
            class="empty-text">
        <text>暂无充值记录</text>
      </view>
      <view v-else
            class="recharge-list">
        <view v-for="row in rechargeList"
              :key="row.id"
              class="recharge-item">
          <view class="recharge-main">
            <text class="recharge-amount">+{{ Number(row.rechargeAmount || 0).toFixed(2) }}</text>
            <text class="recharge-date">{{ row.rechargeDate || "-" }}</text>
          </view>
          <view class="recharge-meta">
            <text v-if="row.createUserName"
                  class="recharge-user">{{ row.createUserName }}</text>
            <text v-if="row.remark"
                  class="recharge-remark">{{ row.remark }}</text>
          </view>
          <view class="recharge-actions">
            <up-button size="mini"
                       type="error"
                       plain
                       text="删除"
                       @click="handleDelete(row)" />
          </view>
        </view>
      </view>
    </view>
    <up-popup :show="showForm"
              mode="bottom"
              :round="16"
              @close="showForm = false">
      <view class="form-panel">
        <view class="form-panel__header">
          <text class="form-panel__title">新增充值</text>
          <u-icon name="close"
                  size="20"
                  color="#909399"
                  @click="showForm = false"></u-icon>
        </view>
        <up-form ref="formRef"
                 :model="form"
                 :rules="rules"
                 label-width="90"
                 input-align="right">
          <up-form-item label="充值金额"
                        prop="rechargeAmount"
                        required>
            <up-input v-model="form.rechargeAmount"
                      type="number"
                      placeholder="请输入充值金额"
                      clearable />
          </up-form-item>
          <up-form-item label="充值日期"
                        prop="rechargeDate"
                        required>
            <up-input v-model="form.rechargeDate"
                      placeholder="请选择充值日期"
                      readonly
                      @click="showDatePicker = true" />
            <template #right>
              <up-icon name="arrow-right"
                       @click="showDatePicker = true"></up-icon>
            </template>
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-input v-model="form.remark"
                      placeholder="请输入备注"
                      clearable />
          </up-form-item>
        </up-form>
        <view class="form-panel__footer">
          <up-button class="form-panel__btn"
                     text="取消"
                     @click="showForm = false" />
          <up-button class="form-panel__btn"
                     type="primary"
                     text="确定"
                     :loading="submitting"
                     @click="submitForm" />
        </view>
      </view>
    </up-popup>
    <up-datetime-picker :show="showDatePicker"
                        v-model="dateValue"
                        mode="datetime"
                        @confirm="onDateConfirm"
                        @cancel="showDatePicker = false" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { unwrapList } from "@/utils/list";
  import { parseTime } from "@/utils/ruoyi";
  import {
    addRecharge,
    delRecharge,
    listAllFuelCard,
    listRecharge,
  } from "@/api/inventoryManagement/vehicle";
  const cardId = ref("");
  const card = ref({});
  const rechargeList = ref([]);
  const showForm = ref(false);
  const showDatePicker = ref(false);
  const submitting = ref(false);
  const formRef = ref();
  const dateValue = ref(Date.now());
  const form = ref({
    cardId: "",
    rechargeAmount: "",
    rechargeDate: "",
    remark: "",
  });
  const rules = {
    rechargeAmount: [
      { required: true, message: "请输入充值金额", trigger: "blur" },
    ],
    rechargeDate: [
      { required: true, message: "请选择充值日期", trigger: "change" },
    ],
  };
  // ä½™é¢ç”±å……值/加油记录在后端维护,前端只展示
  const currentBalance = computed(() =>
    card.value.currentBalance === undefined ? null : card.value.currentBalance
  );
  const goBack = () => uni.navigateBack();
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const loadCard = async () => {
    const cached = uni.getStorageSync("fuelCardDetail");
    if (cached && String(cached.id) === String(cardId.value)) {
      card.value = cached;
    }
    try {
      const res = await listAllFuelCard();
      const rows = unwrapList(res);
      const found = rows.find(item => String(item.id) === String(cardId.value));
      if (found) card.value = found;
    } catch (error) {
      // æ‹‰ä¸åˆ°å°±æ²¿ç”¨ç¼“å­˜
    }
  };
  const loadRecharges = () => {
    listRecharge({ current: -1, size: -1, cardId: cardId.value })
      .then(res => {
        rechargeList.value = unwrapList(res);
      })
      .catch(() => {
        rechargeList.value = [];
      });
  };
  const refresh = () => {
    loadCard();
    loadRecharges();
  };
  const openForm = () => {
    form.value = {
      cardId: cardId.value,
      rechargeAmount: "",
      rechargeDate: parseTime(Date.now()),
      remark: "",
    };
    showForm.value = true;
  };
  const onDateConfirm = e => {
    form.value.rechargeDate = parseTime(e.value);
    showDatePicker.value = false;
  };
  const submitForm = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    submitting.value = true;
    addRecharge({
      ...form.value,
      rechargeAmount: Number(form.value.rechargeAmount || 0),
    })
      .then(() => {
        uni.showToast({ title: "充值成功", icon: "success" });
        showForm.value = false;
        refresh();
      })
      .catch(() => {
        uni.showToast({ title: "充值失败", icon: "none" });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  const handleDelete = row => {
    if (!row?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该笔充值记录吗?(删除后余额将自动扣回)",
      success: res => {
        if (!res.confirm) return;
        delRecharge(row.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            refresh();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          });
      },
    });
  };
  onLoad(options => {
    cardId.value = options?.id || "";
    refresh();
  });
</script>
<style scoped lang="scss">
  .recharge-page {
    min-height: 100vh;
    background: #f8f9fa;
    padding: 12px 12px 24px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 55%;
    word-break: break-all;
  }
  .hero-balance {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
  }
  .hero-label {
    font-size: 12px;
    color: #7a8599;
  }
  .hero-value {
    font-size: 18px;
    font-weight: 600;
    color: #2979ff;
  }
  .section {
    margin-top: 12px;
    padding: 12px;
    background: #ffffff;
    border-radius: 12px;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .section-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: #22324d;
  }
  .empty-text {
    padding: 24px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  .recharge-item {
    padding: 12px 0;
    border-bottom: 1px solid #f2f4f8;
  }
  .recharge-item:last-child {
    border-bottom: none;
  }
  .recharge-main {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .recharge-amount {
    font-size: 15px;
    font-weight: 600;
    color: #19be6b;
  }
  .recharge-date {
    font-size: 12px;
    color: #909399;
  }
  .recharge-meta {
    display: flex;
    gap: 12px;
    margin-top: 4px;
  }
  .recharge-user,
  .recharge-remark {
    font-size: 12px;
    color: #909399;
    word-break: break-all;
  }
  .recharge-actions {
    display: flex;
    justify-content: flex-end;
    margin-top: 8px;
  }
  .form-panel {
    padding: 16px;
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
  }
  .form-panel__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding-bottom: 12px;
    border-bottom: 1px solid #f0f0f0;
  }
  .form-panel__title {
    font-size: 16px;
    font-weight: 600;
    color: #303133;
  }
  .form-panel__footer {
    display: flex;
    gap: 12px;
    padding-top: 16px;
  }
  .form-panel__btn {
    flex: 1;
  }
</style>
src/pages/inventoryManagement/fuelRecord/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,255 @@
<template>
  <view class="fuel-record-detail">
    <PageHeader title="加油记录详情"
                @back="goBack">
      <template #right>
        <text v-if="isDraft"
              class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <text class="hero-title">{{ detail.plateNumber || "-" }}</text>
        <up-tag :text="statusLabel"
                :type="isDraft ? 'info' : 'success'"
                size="mini"
                shape="circle" />
      </view>
      <u-cell-group title="加油信息"
                    class="detail-section">
        <u-cell title="加油日期"
                :value="detail.fuelDate || '-'" />
        <u-cell title="所属部门"
                :value="detail.deptName || '-'" />
        <u-cell title="加油车辆"
                :value="detail.plateNumber || '-'" />
        <u-cell title="加油卡号"
                :value="detail.cardNumber || '-'" />
        <u-cell title="发票号码"
                :value="detail.invoiceNo || '-'" />
      </u-cell-group>
      <u-cell-group title="金额与里程"
                    class="detail-section">
        <u-cell title="金额"
                :value="formatAmount(detail.amount)" />
        <u-cell title="升数(L)"
                :value="detail.liters ?? '-'" />
        <u-cell title="起始里程(km)"
                :value="detail.startMileage ?? '-'" />
        <u-cell title="结束里程(km)"
                :value="detail.endMileage ?? '-'" />
        <u-cell title="本次行驶(km)"
                :value="travelDistance" />
      </u-cell-group>
      <u-cell-group title="备注"
                    class="detail-section">
        <u-cell title="备注"
                :value="detail.remark || '-'" />
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
    <view v-if="!loading"
          class="footer-btns">
      <up-button class="footer-btn"
                 text="返回"
                 @click="goBack" />
      <up-button class="footer-btn"
                 :type="isDraft ? 'primary' : 'warning'"
                 :text="isDraft ? '提交' : '撤回'"
                 @click="handleStatusAction" />
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { unwrapList } from "@/utils/list";
  import {
    listFuelRecord,
    revokeFuelRecord,
    submitFuelRecord,
  } from "@/api/inventoryManagement/vehicle";
  const loading = ref(true);
  const recordId = ref("");
  const detail = ref({});
  // status åŽç«¯è¿”回字符串 '0' è‰ç¨¿ / '1' å·²æäº¤
  const isDraft = computed(
    () => detail.value.status === "0" || !detail.value.status
  );
  const statusLabel = computed(() => (isDraft.value ? "草稿" : "已提交"));
  const travelDistance = computed(() => {
    const start = Number(detail.value.startMileage);
    const end = Number(detail.value.endMileage);
    if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
      return "-";
    }
    return String(end - start);
  });
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("fuelRecordDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelRecord/edit?id=${recordId.value}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  // åŠ æ²¹è®°å½•æ²¡æœ‰å•æ¡è¯¦æƒ…æŽ¥å£ï¼Œä»Žåˆ—è¡¨é‡ŒæŒ‰ id æž
  const loadDetail = async () => {
    loading.value = true;
    const cached = uni.getStorageSync("fuelRecordDetail");
    if (cached && String(cached.id) === String(recordId.value)) {
      detail.value = cached;
    }
    try {
      const res = await listFuelRecord({ current: -1, size: -1 });
      const found = unwrapList(res).find(
        item => String(item.id) === String(recordId.value)
      );
      if (found) detail.value = found;
    } catch (error) {
      // æ‹‰ä¸åˆ°å°±æ²¿ç”¨ç¼“å­˜
    } finally {
      loading.value = false;
    }
  };
  const handleStatusAction = () => {
    if (!recordId.value) return;
    const submitting = isDraft.value;
    uni.showModal({
      title: submitting ? "提交确认" : "撤回确认",
      content: submitting
        ? "确认提交该笔加油记录吗?提交后将从加油卡中扣除余额。"
        : "确认撤回该笔加油记录吗?撤回后将退还加油卡余额。",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        const action = submitting ? submitFuelRecord : revokeFuelRecord;
        action(recordId.value)
          .then(() => {
            uni.showToast({
              title: submitting ? "提交成功" : "撤回成功",
              icon: "success",
            });
            loadDetail();
          })
          .catch(() => {
            uni.showToast({
              title: submitting ? "提交失败" : "撤回失败",
              icon: "none",
            });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onLoad(options => {
    recordId.value = options?.id || "";
    if (!recordId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .fuel-record-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 90px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  .footer-btns {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    gap: 12px;
    padding: 12px 16px;
    padding-bottom: calc(12px + env(safe-area-inset-bottom));
    background: #ffffff;
    box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
    z-index: 1000;
  }
  .footer-btn {
    flex: 1;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/inventoryManagement/fuelRecord/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,295 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="110"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="加油信息"
                      class="form-section">
          <up-form-item label="加油车辆"
                        prop="vehicleId"
                        required>
            <FormPicker v-model="form.vehicleId"
                        type="select"
                        title="选择加油车辆"
                        :options="vehicleOptions"
                        placeholder="请选择加油车辆" />
          </up-form-item>
          <up-form-item label="关联加油卡"
                        prop="fuelCardId"
                        required>
            <FormPicker v-model="form.fuelCardId"
                        type="select"
                        title="选择加油卡"
                        :options="cardOptions"
                        placeholder="请选择加油卡" />
            <view v-if="currentBalanceText"
                  class="field-tip">
              <text>{{ currentBalanceText }}</text>
            </view>
          </up-form-item>
          <up-form-item label="加油日期"
                        prop="fuelDate"
                        required>
            <FormPicker v-model="form.fuelDate"
                        type="date"
                        placeholder="请选择加油日期" />
          </up-form-item>
          <up-form-item label="发票号码"
                        prop="invoiceNo">
            <up-input v-model="form.invoiceNo"
                      placeholder="请输入发票号码"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="金额与里程"
                      class="form-section">
          <up-form-item label="金额(元)"
                        prop="amount"
                        required>
            <up-input v-model="form.amount"
                      type="number"
                      placeholder="请输入金额"
                      clearable />
          </up-form-item>
          <up-form-item label="升数(L)"
                        prop="liters">
            <up-input v-model="form.liters"
                      type="number"
                      placeholder="请输入升数"
                      clearable />
          </up-form-item>
          <up-form-item label="起始里程"
                        prop="startMileage">
            <up-input v-model="form.startMileage"
                      type="number"
                      placeholder="请输入起始里程(km)"
                      clearable />
          </up-form-item>
          <up-form-item label="结束里程"
                        prop="endMileage">
            <up-input v-model="form.endMileage"
                      type="number"
                      placeholder="请输入结束里程(km)"
                      clearable />
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-textarea v-model="form.remark"
                         placeholder="请输入备注"
                         auto-height />
          </up-form-item>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, onMounted, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import FooterButtons from "@/components/FooterButtons.vue";
  import PageHeader from "@/components/PageHeader.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { loadDetailData } from "@/utils/detailCache";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import {
    addFuelRecord,
    listAllFuelCard,
    listFuelRecord,
    listVehicleInfo,
    updateFuelRecord,
  } from "@/api/inventoryManagement/vehicle";
  const formRef = ref();
  const loading = ref(false);
  const recordId = ref("");
  const vehicleOptions = ref([]);
  const allCards = ref([]);
  const form = ref({
    id: undefined,
    vehicleId: "",
    fuelCardId: "",
    fuelDate: "",
    invoiceNo: "",
    amount: "",
    liters: "",
    startMileage: "",
    endMileage: "",
    remark: "",
  });
  const rules = {
    vehicleId: [{ required: true, message: "请选择加油车辆", trigger: "change" }],
    fuelCardId: [{ required: true, message: "请选择加油卡", trigger: "change" }],
    fuelDate: [{ required: true, message: "请选择加油日期", trigger: "change" }],
    amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
  };
  const pageTitle = computed(() =>
    recordId.value ? "编辑加油记录" : "新增加油记录"
  );
  // å·²æ³¨é”€çš„卡不能用于新增加油记录
  const cardOptions = computed(() =>
    allCards.value
      .filter(item => item.status !== "CANCELLED")
      .map(item => ({
        name: `${item.cardNumber || ""} - ${item.issuer || ""}`,
        value: item.id,
      }))
  );
  const currentBalanceText = computed(() => {
    if (!form.value.fuelCardId) return "";
    const card = allCards.value.find(
      item => String(item.id) === String(form.value.fuelCardId)
    );
    if (!card) return "";
    return `当前余额: ${Number(card.currentBalance || 0).toFixed(2)} å…ƒ`;
  });
  const goBack = () => uni.navigateBack();
  const loadOptions = async () => {
    const [vehicles, cards] = await Promise.all([
      listVehicleInfo({ current: -1, size: -1 }).catch(() => ({})),
      listAllFuelCard().catch(() => ({})),
    ]);
    const vehicleRows =
      vehicles?.data?.records || vehicles?.records || vehicles?.data || [];
    vehicleOptions.value = (Array.isArray(vehicleRows) ? vehicleRows : []).map(
      item => ({ name: item.plateNumber, value: item.id })
    );
    const cardRows = cards?.data || [];
    allCards.value = Array.isArray(cardRows) ? cardRows : [];
  };
  // åŠ æ²¹è®°å½•æ²¡æœ‰å•æ¡è¯¦æƒ…æŽ¥å£ï¼Œä»Žåˆ—è¡¨é‡ŒæŒ‰ id æž
  const fetchRecord = async id => {
    const res = await listFuelRecord({ current: -1, size: -1 });
    const rows = res?.data?.records || res?.records || res?.data || [];
    const found = (Array.isArray(rows) ? rows : []).find(
      item => String(item.id) === String(id)
    );
    return { data: found };
  };
  const loadDetail = async () => {
    if (!recordId.value) return;
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "fuelRecordDetail",
        id: recordId.value,
        fetcher: fetchRecord,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "获取加油记录失败", icon: "none" });
      return;
    }
    form.value = { ...form.value, ...data, id: data.id };
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    const card = allCards.value.find(
      item => String(item.id) === String(form.value.fuelCardId)
    );
    const balance = Number(card?.currentBalance || 0);
    const amount = Number(form.value.amount || 0);
    if (!recordId.value && balance < amount) {
      uni.showToast({
        title: `该加油卡余额不足,当前余额 ${balance} å…ƒ`,
        icon: "none",
      });
    }
    loading.value = true;
    const action = recordId.value ? updateFuelRecord : addFuelRecord;
    action({
      ...form.value,
      id: recordId.value || undefined,
      amount: amount,
      liters: Number(form.value.liters || 0),
      startMileage: Number(form.value.startMileage || 0),
      endMileage: Number(form.value.endMileage || 0),
    })
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 300);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      recordId.value = options.id;
      form.value.id = options.id;
    } else {
      form.value.fuelDate = formatDateToYMD(Date.now());
    }
  });
  onMounted(async () => {
    await loadOptions();
    if (recordId.value) await loadDetail();
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .field-tip {
    padding-top: 4px;
    font-size: 12px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/inventoryManagement/fuelRecord/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,377 @@
<template>
  <view class="sales-account">
    <PageHeader title="加油记录"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.plateNumber"
                    placeholder="请输入车牌号码搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="recordList.length > 0"
          class="ledger-list">
      <view v-for="item in recordList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="car-fill"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.plateNumber || "-" }}</text>
          </view>
          <up-tag :text="statusLabel(item.status)"
                  :type="isDraft(item) ? 'info' : 'success'"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">加油日期</text>
            <text class="detail-value">{{ item.fuelDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">所属部门</text>
            <text class="detail-value">{{ item.deptName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">加油卡号</text>
            <text class="detail-value">{{ item.cardNumber || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">金额</text>
            <text class="detail-value highlight">{{ formatAmount(item.amount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">升数</text>
            <text class="detail-value">{{ item.liters ?? "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">起始里程</text>
            <text class="detail-value">{{ item.startMileage ?? "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">结束里程</text>
            <text class="detail-value">{{ item.endMileage ?? "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发票号码</text>
            <text class="detail-value">{{ item.invoiceNo || "-" }}</text>
          </view>
          <view class="detail-row"
                v-if="item.remark">
            <text class="detail-label">备注</text>
            <text class="detail-value">{{ item.remark }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button v-if="isDraft(item)"
                     class="action-btn"
                     size="small"
                     type="success"
                     @click="handleSubmitRow(item)">提交</up-button>
          <up-button v-if="isDraft(item)"
                     class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button v-if="isDraft(item)"
                     class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
          <up-button v-if="!isDraft(item)"
                     class="action-btn"
                     size="small"
                     type="warning"
                     plain
                     @click="handleRevokeRow(item)">撤回</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无加油记录数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="加油记录筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess";
  import { unwrapList } from "@/utils/list";
  import {
    delFuelRecord,
    listAllFuelCard,
    listFuelRecord,
    listVehicleInfo,
    revokeFuelRecord,
    submitFuelRecord,
  } from "@/api/inventoryManagement/vehicle";
  const STATUS_OPTIONS = [
    { name: "草稿", value: "0" },
    { name: "已提交", value: "1" },
  ];
  const recordList = ref([]);
  const showFilter = ref(false);
  const deptOptions = ref([]);
  const vehicleOptions = ref([]);
  const cardOptions = ref([]);
  const query = reactive({
    plateNumber: "",
    deptId: "",
    vehicleId: "",
    fuelCardId: "",
    status: "",
    fuelDateRange: [],
  });
  const filterFields = computed(() => [
    {
      prop: "plateNumber",
      label: "车牌号码",
      type: "input",
      placeholder: "请输入车牌号码",
    },
    {
      prop: "deptId",
      label: "所属部门",
      type: "select",
      options: deptOptions.value,
    },
    {
      prop: "vehicleId",
      label: "加油车辆",
      type: "select",
      options: vehicleOptions.value,
    },
    {
      prop: "fuelCardId",
      label: "加油卡",
      type: "select",
      options: cardOptions.value,
    },
    {
      prop: "status",
      label: "状态",
      type: "select",
      options: STATUS_OPTIONS,
    },
    { prop: "fuelDateRange", label: "加油日期", type: "daterange" },
  ]);
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("fuelRecordDetail");
    uni.navigateTo({ url: "/pages/inventoryManagement/fuelRecord/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("fuelRecordDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelRecord/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("fuelRecordDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/fuelRecord/detail?id=${item.id}`,
    });
  };
  // status åŽç«¯è¿”回字符串 '0' è‰ç¨¿ / '1' å·²æäº¤
  const isDraft = item => item?.status === "0" || !item?.status;
  const statusLabel = status => (status === "1" ? "已提交" : "草稿");
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const getList = () => {
    const [beginDate, endDate] = Array.isArray(query.fuelDateRange)
      ? query.fuelDateRange
      : [];
    listFuelRecord({
      current: -1,
      size: -1,
      deptId: query.deptId || undefined,
      vehicleId: query.vehicleId || undefined,
      fuelCardId: query.fuelCardId || undefined,
      status: query.status || undefined,
      beginDate: beginDate || undefined,
      endDate: endDate || undefined,
    })
      .then(res => {
        const rows = unwrapList(res);
        // è½¦ç‰Œæœç´¢æ¡†åœ¨ç­›é€‰æ¡ä»¶é‡Œæ²¡æœ‰å¯¹åº”参数,本地过滤兜底
        recordList.value = query.plateNumber
          ? rows.filter(item =>
              String(item.plateNumber || "").includes(query.plateNumber)
            )
          : rows;
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleSubmitRow = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "提交确认",
      content: "确认提交该笔加油记录吗?提交后将从加油卡中扣除余额。",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        submitFuelRecord(item.id)
          .then(() => {
            uni.showToast({ title: "提交成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "提交失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  const handleRevokeRow = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "撤回确认",
      content: "确认撤回该笔加油记录吗?撤回后将退还加油卡余额。",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        revokeFuelRecord(item.id)
          .then(() => {
            uni.showToast({ title: "撤回成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "撤回失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该草稿记录吗?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delFuelRecord(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  let optionsLoaded = false;
  const loadOptions = () => {
    if (optionsLoaded) return;
    optionsLoaded = true;
    Promise.all([
      getDept().catch(() => ({})),
      listVehicleInfo({ current: -1, size: -1 }).catch(() => ({})),
      listAllFuelCard().catch(() => ({})),
    ])
      .then(([depts, vehicles, cards]) => {
        deptOptions.value = (depts?.data || []).map(item => ({
          name: item.deptName,
          value: item.deptId,
        }));
        vehicleOptions.value = unwrapList(vehicles).map(item => ({
          name: item.plateNumber,
          value: item.id,
        }));
        cardOptions.value = unwrapList(cards).map(item => ({
          name: `${item.cardNumber || ""} - ${item.issuer || ""}`,
          value: item.id,
        }));
      })
      .catch(() => {
        optionsLoaded = false;
      });
  };
  onShow(() => {
    getList();
    loadOptions();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/inventoryManagement/vehicleInfo/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,510 @@
<template>
  <view class="vehicle-detail">
    <PageHeader title="车辆详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <text class="hero-title">{{ detail.plateNumber || "-" }}</text>
        <up-tag :text="statusLabel(detail.vehicleStatus)"
                :type="statusTagType(detail.vehicleStatus)"
                size="mini"
                shape="circle" />
      </view>
      <view v-if="warnItems.length"
            class="warn-banner">
        <up-icon name="error-circle"
                 size="16"
                 color="#ff9900"></up-icon>
        <text class="warn-text">{{ warnItems.join(";") }}</text>
      </view>
      <u-cell-group title="车辆信息"
                    class="detail-section">
        <u-cell title="车牌号码"
                :value="detail.plateNumber || '-'" />
        <u-cell title="产权所有"
                :value="detail.propertyOwnership || '-'" />
        <u-cell title="所属部门"
                :value="detail.deptName || '-'" />
        <u-cell title="责任人"
                :value="detail.userName || '-'" />
        <u-cell title="状态"
                :value="statusLabel(detail.vehicleStatus)" />
        <u-cell title="年检到期"
                :value="detail.inspectionExpireDate || '-'" />
        <u-cell title="保险到期"
                :value="detail.insuranceExpireDate || '-'" />
        <u-cell title="备注"
                :value="detail.remark || '-'" />
      </u-cell-group>
      <u-cell-group title="费用管理"
                    class="detail-section">
        <view class="section-tools">
          <up-button type="primary"
                     size="small"
                     text="新增费用"
                     @click="openCostForm" />
        </view>
        <view v-if="costList.length === 0"
              class="empty-text">
          <text>暂无费用记录</text>
        </view>
        <view v-else
              class="cost-list">
          <view v-for="cost in costList"
                :key="cost.id"
                class="cost-item">
            <view class="cost-main">
              <text class="cost-type">{{ costTypeLabel(cost.costType) }}</text>
              <text class="cost-amount">{{ formatAmount(cost.amount) }}</text>
            </view>
            <view class="cost-meta">
              <text class="cost-date">{{ cost.costDate || "-" }}</text>
              <text v-if="cost.remark"
                    class="cost-remark">{{ cost.remark }}</text>
            </view>
            <view class="cost-actions">
              <up-button size="mini"
                         type="error"
                         plain
                         text="删除"
                         @click="handleDeleteCost(cost)" />
            </view>
          </view>
        </view>
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
    <up-popup :show="showCostForm"
              mode="bottom"
              :round="16"
              @close="showCostForm = false">
      <view class="cost-form">
        <view class="cost-form__header">
          <text class="cost-form__title">新增费用</text>
          <u-icon name="close"
                  size="20"
                  color="#909399"
                  @click="showCostForm = false"></u-icon>
        </view>
        <up-form ref="costFormRef"
                 :model="costForm"
                 :rules="costRules"
                 label-width="90"
                 input-align="right">
          <up-form-item label="费用类型"
                        prop="costType"
                        required>
            <FormPicker v-model="costForm.costType"
                        type="select"
                        title="选择费用类型"
                        :options="COST_TYPE_OPTIONS"
                        placeholder="请选择费用类型" />
          </up-form-item>
          <up-form-item label="金额(元)"
                        prop="amount"
                        required>
            <up-input v-model="costForm.amount"
                      type="number"
                      placeholder="请输入金额"
                      clearable />
          </up-form-item>
          <up-form-item label="发生日期"
                        prop="costDate"
                        required>
            <FormPicker v-model="costForm.costDate"
                        type="date"
                        placeholder="请选择发生日期" />
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-input v-model="costForm.remark"
                      placeholder="请输入备注"
                      clearable />
          </up-form-item>
        </up-form>
        <view class="cost-form__footer">
          <up-button class="cost-form__btn"
                     text="取消"
                     @click="showCostForm = false" />
          <up-button class="cost-form__btn"
                     type="primary"
                     text="确定"
                     :loading="costSubmitting"
                     @click="submitCost" />
        </view>
      </view>
    </up-popup>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { unwrapList } from "@/utils/list";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { getWarnInfo } from "@/utils/warning";
  import {
    addVehicleCost,
    delVehicleCost,
    listVehicleCost,
    listVehicleInfo,
  } from "@/api/inventoryManagement/vehicle";
  const COST_TYPE_OPTIONS = [
    { name: "保养", value: "MAINTENANCE" },
    { name: "年检", value: "INSPECTION" },
    { name: "保险", value: "INSURANCE" },
    { name: "ç»´ä¿®", value: "REPAIR" },
  ];
  const loading = ref(true);
  const vehicleId = ref("");
  const detail = ref({});
  const costList = ref([]);
  const showCostForm = ref(false);
  const costSubmitting = ref(false);
  const costFormRef = ref();
  const costForm = ref({
    vehicleId: "",
    costType: "",
    amount: "",
    costDate: "",
    remark: "",
  });
  const costRules = {
    costType: [{ required: true, message: "请选择费用类型", trigger: "change" }],
    amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
    costDate: [{ required: true, message: "请选择发生日期", trigger: "change" }],
  };
  // éœ€æ±‚十一:年检/保险到期前 30 å¤©é¢„è­¦
  const warnItems = computed(() => {
    const items = [];
    const inspection = getWarnInfo(detail.value.inspectionExpireDate, 30);
    if (inspection.text && inspection.level !== "normal") {
      items.push(`年检${inspection.text}`);
    }
    const insurance = getWarnInfo(detail.value.insuranceExpireDate, 30);
    if (insurance.text && insurance.level !== "normal") {
      items.push(`保险${insurance.text}`);
    }
    return items;
  });
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("vehicleInfoDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/vehicleInfo/edit?id=${vehicleId.value}`,
    });
  };
  const statusLabel = status => {
    if (status === "NORMAL") return "在用";
    if (status === "IDLE") return "闲置";
    if (status === "REPAIR") return "ç»´ä¿®";
    return status || "未填状态";
  };
  const statusTagType = status => {
    if (status === "NORMAL") return "success";
    if (status === "IDLE") return "info";
    if (status === "REPAIR") return "warning";
    return "info";
  };
  const costTypeLabel = type =>
    COST_TYPE_OPTIONS.find(item => item.value === type)?.name || type || "-";
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  // è½¦è¾†æ¨¡å—没有单条详情接口,从列表里按 id æž
  const loadVehicle = async () => {
    const cached = uni.getStorageSync("vehicleInfoDetail");
    if (cached && String(cached.id) === String(vehicleId.value)) {
      detail.value = cached;
    }
    try {
      const res = await listVehicleInfo({ current: -1, size: -1 });
      const rows = unwrapList(res);
      const found = rows.find(
        item => String(item.id) === String(vehicleId.value)
      );
      if (found) detail.value = found;
    } catch (error) {
      // æ‹‰ä¸åˆ°å°±æ²¿ç”¨ç¼“存,下面还有空态兜底
    }
  };
  const loadCosts = () => {
    listVehicleCost({ current: -1, size: -1, vehicleId: vehicleId.value })
      .then(res => {
        costList.value = unwrapList(res);
      })
      .catch(() => {
        costList.value = [];
      });
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      await loadVehicle();
      loadCosts();
    } finally {
      loading.value = false;
    }
  };
  const openCostForm = () => {
    costForm.value = {
      vehicleId: vehicleId.value,
      costType: "",
      amount: "",
      costDate: formatDateToYMD(Date.now()),
      remark: "",
    };
    showCostForm.value = true;
  };
  const submitCost = async () => {
    const valid = await costFormRef.value.validate().catch(() => false);
    if (!valid) return;
    costSubmitting.value = true;
    addVehicleCost({
      ...costForm.value,
      amount: Number(costForm.value.amount || 0),
    })
      .then(() => {
        uni.showToast({ title: "添加成功", icon: "success" });
        showCostForm.value = false;
        loadCosts();
      })
      .catch(() => {
        uni.showToast({ title: "添加失败", icon: "none" });
      })
      .finally(() => {
        costSubmitting.value = false;
      });
  };
  const handleDeleteCost = cost => {
    if (!cost?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该费用记录吗?",
      success: res => {
        if (!res.confirm) return;
        delVehicleCost(cost.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            loadCosts();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          });
      },
    });
  };
  onLoad(options => {
    vehicleId.value = options?.id || "";
    if (!vehicleId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .vehicle-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .warn-banner {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-top: 12px;
    padding: 10px 14px;
    border-radius: 10px;
    background: #fff7ea;
  }
  .warn-text {
    flex: 1;
    font-size: 13px;
    color: #e6a23c;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .section-tools {
    display: flex;
    justify-content: flex-end;
    padding: 12px 12px 0;
  }
  .empty-text {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .cost-list {
    padding: 6px 12px 12px;
  }
  .cost-item {
    padding: 12px 0;
    border-bottom: 1px solid #f2f4f8;
  }
  .cost-item:last-child {
    border-bottom: none;
  }
  .cost-main {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .cost-type {
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
  }
  .cost-amount {
    font-size: 14px;
    font-weight: 600;
    color: #2979ff;
  }
  .cost-meta {
    display: flex;
    gap: 12px;
    margin-top: 4px;
  }
  .cost-date {
    font-size: 12px;
    color: #909399;
  }
  .cost-remark {
    flex: 1;
    font-size: 12px;
    color: #909399;
    word-break: break-all;
  }
  .cost-actions {
    display: flex;
    justify-content: flex-end;
    margin-top: 8px;
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  .cost-form {
    padding: 16px;
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
  }
  .cost-form__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding-bottom: 12px;
    border-bottom: 1px solid #f0f0f0;
  }
  .cost-form__title {
    font-size: 16px;
    font-weight: 600;
    color: #303133;
  }
  .cost-form__footer {
    display: flex;
    gap: 12px;
    padding-top: 16px;
  }
  .cost-form__btn {
    flex: 1;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/inventoryManagement/vehicleInfo/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,249 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="110"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="车辆信息"
                      class="form-section">
          <up-form-item label="车牌号码"
                        prop="plateNumber"
                        required>
            <up-input v-model="form.plateNumber"
                      placeholder="请输入车牌号码"
                      clearable />
          </up-form-item>
          <up-form-item label="产权所有"
                        prop="propertyOwnership">
            <up-input v-model="form.propertyOwnership"
                      placeholder="如:自有、租赁等"
                      clearable />
          </up-form-item>
          <up-form-item label="所属部门"
                        prop="deptId"
                        required>
            <FormPicker v-model="form.deptId"
                        type="select"
                        title="选择所属部门"
                        :options="deptOptions"
                        placeholder="请选择所属部门"
                        @change="onDeptChange" />
          </up-form-item>
          <up-form-item label="责任人"
                        prop="userId">
            <FormPicker v-model="form.userId"
                        type="select"
                        title="选择责任人"
                        :options="userOptions"
                        placeholder="请选择责任人"
                        @change="onUserChange" />
          </up-form-item>
          <up-form-item label="状态"
                        prop="vehicleStatus"
                        required>
            <FormPicker v-model="form.vehicleStatus"
                        type="select"
                        title="选择状态"
                        :options="VEHICLE_STATUS_OPTIONS"
                        placeholder="请选择状态" />
          </up-form-item>
          <up-form-item label="年检到期"
                        prop="inspectionExpireDate">
            <FormPicker v-model="form.inspectionExpireDate"
                        type="date"
                        placeholder="请选择年检到期日期" />
          </up-form-item>
          <up-form-item label="保险到期"
                        prop="insuranceExpireDate">
            <FormPicker v-model="form.insuranceExpireDate"
                        type="date"
                        placeholder="请选择保险到期日期" />
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-textarea v-model="form.remark"
                         placeholder="请输入备注"
                         auto-height />
          </up-form-item>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, onMounted, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import FooterButtons from "@/components/FooterButtons.vue";
  import PageHeader from "@/components/PageHeader.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { loadDetailData } from "@/utils/detailCache";
  import {
    addVehicleInfo,
    listVehicleInfo,
    updateVehicleInfo,
  } from "@/api/inventoryManagement/vehicle";
  const VEHICLE_STATUS_OPTIONS = [
    { name: "在用", value: "NORMAL" },
    { name: "闲置", value: "IDLE" },
    { name: "ç»´ä¿®", value: "REPAIR" },
  ];
  const formRef = ref();
  const loading = ref(false);
  const vehicleId = ref("");
  const deptOptions = ref([]);
  const userOptions = ref([]);
  const form = ref({
    id: undefined,
    plateNumber: "",
    propertyOwnership: "",
    deptId: "",
    deptName: "",
    userId: "",
    userName: "",
    vehicleStatus: "NORMAL",
    inspectionExpireDate: "",
    insuranceExpireDate: "",
    remark: "",
  });
  const rules = {
    plateNumber: [
      { required: true, message: "请输入车牌号码", trigger: "blur" },
    ],
    deptId: [{ required: true, message: "请选择所属部门", trigger: "change" }],
    vehicleStatus: [{ required: true, message: "请选择状态", trigger: "change" }],
  };
  const pageTitle = computed(() => (vehicleId.value ? "编辑车辆" : "新增车辆"));
  const goBack = () => uni.navigateBack();
  const onDeptChange = (value, item) => {
    form.value.deptName = item?.name || "";
  };
  const onUserChange = (value, item) => {
    form.value.userName = item?.name || "";
  };
  const loadOptions = async () => {
    const [depts, users] = await Promise.all([
      getDept().catch(() => ({})),
      userListNoPageByTenantId().catch(() => ({})),
    ]);
    deptOptions.value = (depts?.data || []).map(item => ({
      name: item.deptName,
      value: item.deptId,
    }));
    const rows = users?.data || [];
    userOptions.value = (Array.isArray(rows) ? rows : []).map(item => ({
      name: item.nickName || item.userName || `用户${item.userId}`,
      value: item.userId,
    }));
  };
  // è½¦è¾†æ¨¡å—没有单条详情接口,从列表里按 id æžï¼ŒåŒ…装成 loadDetailData æœŸæœ›çš„响应形状
  const fetchVehicle = async id => {
    const res = await listVehicleInfo({ current: -1, size: -1 });
    const rows = res?.data?.records || res?.records || res?.data || [];
    const found = (Array.isArray(rows) ? rows : []).find(
      item => String(item.id) === String(id)
    );
    return { data: found };
  };
  const loadDetail = async () => {
    if (!vehicleId.value) return;
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "vehicleInfoDetail",
        id: vehicleId.value,
        fetcher: fetchVehicle,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "获取车辆详情失败", icon: "none" });
      return;
    }
    form.value = { ...form.value, ...data, id: data.id };
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    loading.value = true;
    const action = vehicleId.value ? updateVehicleInfo : addVehicleInfo;
    action({ ...form.value, id: vehicleId.value || undefined })
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 300);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      vehicleId.value = options.id;
      form.value.id = options.id;
    }
  });
  onMounted(async () => {
    await loadOptions();
    if (vehicleId.value) await loadDetail();
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/inventoryManagement/vehicleInfo/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,331 @@
<template>
  <view class="sales-account">
    <PageHeader title="车辆管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.plateNumber"
                    placeholder="请输入车牌号码搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view class="warn-bar">
      <up-button :type="showWarning ? 'warning' : 'primary'"
                 size="small"
                 :plain="!showWarning"
                 :text="showWarning ? '返回全部车辆' : '查看 30 å¤©å†…到期预警'"
                 @click="toggleWarning" />
    </view>
    <view v-if="vehicleList.length > 0"
          class="ledger-list">
      <view v-for="item in vehicleList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="car"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.plateNumber || "-" }}</text>
          </view>
          <up-tag :text="statusLabel(item.vehicleStatus)"
                  :type="statusTagType(item.vehicleStatus)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">所属部门</text>
            <text class="detail-value">{{ item.deptName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">责任人</text>
            <text class="detail-value">{{ item.userName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">产权所有</text>
            <text class="detail-value">{{ item.propertyOwnership || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">年检到期</text>
            <text class="detail-value">{{ item.inspectionExpireDate || "-" }}</text>
          </view>
          <view class="detail-row"
                v-if="inspectionWarn(item).text">
            <text class="detail-label">年检预警</text>
            <text class="detail-value"
                  :style="{ color: inspectionWarn(item).color }">
              {{ inspectionWarn(item).text }}
            </text>
          </view>
          <view class="detail-row">
            <text class="detail-label">保险到期</text>
            <text class="detail-value">{{ item.insuranceExpireDate || "-" }}</text>
          </view>
          <view class="detail-row"
                v-if="insuranceWarn(item).text">
            <text class="detail-label">保险预警</text>
            <text class="detail-value"
                  :style="{ color: insuranceWarn(item).color }">
              {{ insuranceWarn(item).text }}
            </text>
          </view>
          <view class="detail-row"
                v-if="item.remark">
            <text class="detail-label">备注</text>
            <text class="detail-value">{{ item.remark }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="success"
                     @click="goCost(item)">费用管理</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>{{ showWarning ? "暂无 30 å¤©å†…到期的车辆" : "暂无车辆数据" }}</text>
    </view>
    <view v-if="!showWarning"
          class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="车辆筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess";
  import { unwrapList } from "@/utils/list";
  import { getWarnInfo } from "@/utils/warning";
  import {
    delVehicleInfo,
    getVehicleWarningList,
    listVehicleInfo,
  } from "@/api/inventoryManagement/vehicle";
  const VEHICLE_STATUS_OPTIONS = [
    { name: "在用", value: "NORMAL" },
    { name: "闲置", value: "IDLE" },
    { name: "ç»´ä¿®", value: "REPAIR" },
  ];
  const vehicleList = ref([]);
  const showFilter = ref(false);
  const showWarning = ref(false);
  const deptOptions = ref([]);
  const query = reactive({
    plateNumber: "",
    deptId: "",
    vehicleStatus: "",
  });
  const filterFields = computed(() => [
    {
      prop: "plateNumber",
      label: "车牌号码",
      type: "input",
      placeholder: "请输入车牌号码",
    },
    {
      prop: "deptId",
      label: "所属部门",
      type: "select",
      options: deptOptions.value,
    },
    {
      prop: "vehicleStatus",
      label: "状态",
      type: "select",
      options: VEHICLE_STATUS_OPTIONS,
    },
  ]);
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("vehicleInfoDetail");
    uni.navigateTo({ url: "/pages/inventoryManagement/vehicleInfo/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("vehicleInfoDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/vehicleInfo/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("vehicleInfoDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/vehicleInfo/detail?id=${item.id}`,
    });
  };
  const goCost = item => {
    uni.setStorageSync("vehicleInfoDetail", item || {});
    uni.navigateTo({
      url: `/pages/inventoryManagement/vehicleInfo/detail?id=${item.id}&tab=cost`,
    });
  };
  const statusLabel = status => {
    if (status === "NORMAL") return "在用";
    if (status === "IDLE") return "闲置";
    if (status === "REPAIR") return "ç»´ä¿®";
    return status || "未填状态";
  };
  const statusTagType = status => {
    if (status === "NORMAL") return "success";
    if (status === "IDLE") return "info";
    if (status === "REPAIR") return "warning";
    return "info";
  };
  // éœ€æ±‚十一:车辆年检/保险到期前 30 å¤©é¢„è­¦
  const inspectionWarn = item => getWarnInfo(item?.inspectionExpireDate, 30);
  const insuranceWarn = item => getWarnInfo(item?.insuranceExpireDate, 30);
  const getList = () => {
    if (showWarning.value) return;
    listVehicleInfo({
      current: -1,
      size: -1,
      plateNumber: query.plateNumber || undefined,
      deptId: query.deptId || undefined,
      vehicleStatus: query.vehicleStatus || undefined,
    })
      .then(res => {
        vehicleList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  const toggleWarning = () => {
    if (showWarning.value) {
      showWarning.value = false;
      getList();
      return;
    }
    getVehicleWarningList()
      .then(res => {
        vehicleList.value = unwrapList(res);
        showWarning.value = true;
      })
      .catch(() => {
        uni.showToast({ title: "获取预警车辆失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    showWarning.value = false;
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: `确认删除车辆「${item.plateNumber || ""}」吗?`,
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delVehicleInfo(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  let deptsLoaded = false;
  const loadDepts = () => {
    if (deptsLoaded) return;
    deptsLoaded = true;
    getDept()
      .then(res => {
        deptOptions.value = (res?.data || []).map(item => ({
          name: item.deptName,
          value: item.deptId,
        }));
      })
      .catch(() => {
        deptsLoaded = false;
      });
  };
  onShow(() => {
    getList();
    loadDepts();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .warn-bar {
    padding: 0 12px 8px;
  }
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue
@@ -109,6 +109,11 @@
        <text class="rd-section-title">收款信息</text>
      </view>
      <view class="rd-group">
        <view v-if="!isTravel"
              class="rd-cell">
          <text class="rd-label">付款方</text>
          <text class="rd-value">{{ r.payer || "—" }}</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">收款人</text>
          <text class="rd-value">{{ r.payeeName || r.payee || "—" }}</text>
src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js
@@ -95,6 +95,7 @@
    expenseCategory: "other",
    reimburseReason: "",
    applyAmount: "",
    payer: "",
    payee: "",
    payeeAccount: "",
    bankBranch: "",
src/pages/oa/ReimburseManage/reimburse-form/index.vue
@@ -215,6 +215,16 @@
                          input-align="right" />
              </view>
            </view>
            <view v-if="!isTravel"
                  class="rf-cell">
              <text class="rf-label">付款方</text>
              <view class="rf-input-body">
                <up-input v-model="form.payer"
                          placeholder="选填"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label required">收款人</text>
              <view class="rf-input-body">
src/pages/oa/_utils/finReimbursementMappers.js
@@ -778,6 +778,7 @@
    applyAmount: row.applyAmount,
    applyTime: formatReimbursementDateTime(row.createTime),
    createTime: formatReimbursementDateTime(row.createTime),
    payer: row.payer || "",
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    bankBranch: row.payeeBank || "",
@@ -892,6 +893,7 @@
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payer: form.payer || "",
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || "",
    payeeBank: form.bankBranch || form.payeeBank || "",
src/pages/projectManagement/project/_components/AddressRow.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
<!--
  æ”¶è´§åœ°å€å­è¡¨çš„一行
  åŽç«¯ shippingAddress åªå­˜ä¸€æ¡ï¼ˆconsignee=收货人、contract=联系电话、address=地址),
  æ‰€ä»¥è¿™ä¸ªå­è¡¨åœ¨è¡¨å•里只会有一行,提交时按 mapper å–第一行。
-->
<template>
  <view class="address-card">
    <view class="address-header">
      <text class="address-title">收货地址</text>
    </view>
    <up-divider></up-divider>
    <view class="address-body">
      <view class="field-row">
        <text class="field-label">收货人</text>
        <u-input v-model="row.receiver"
                 placeholder="请输入收货人"
                 border="none"
                 inputAlign="right" />
      </view>
      <view class="field-row">
        <text class="field-label">联系方式</text>
        <u-input v-model="row.phone"
                 type="number"
                 placeholder="请输入联系方式"
                 border="none"
                 inputAlign="right" />
      </view>
      <view class="field-row">
        <text class="field-label">收货地址</text>
        <u-input v-model="row.address"
                 placeholder="请输入收货地址"
                 border="none"
                 inputAlign="right" />
      </view>
    </view>
  </view>
</template>
<script setup>
  defineProps({
    row: {
      type: Object,
      required: true,
    },
  });
</script>
<style scoped lang="scss">
  .address-card {
    background: #fff;
    border-radius: 12px;
    padding: 0 12px 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .address-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 0;
  }
  .address-title {
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
  }
  .address-body {
    padding-top: 4px;
  }
  .field-row {
    display: flex;
    align-items: center;
    min-height: 44px;
    border-bottom: 1px solid #f5f6fa;
  }
  .field-row:last-child {
    border-bottom: none;
  }
  .field-label {
    width: 96px;
    flex-shrink: 0;
    font-size: 14px;
    color: #606266;
  }
</style>
src/pages/projectManagement/project/_components/ProductRow.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,363 @@
<!--
  é¡¹ç›®äº§å“ä¿¡æ¯å­è¡¨çš„一行
  row ç”±çˆ¶çº§ productList çš„元素直接传入(同一个响应式对象),
  ç»„件内直接改 row.xxx å³å¯ï¼Œä¸éœ€è¦ emit å›žçˆ¶çº§åˆå¹¶ã€‚
  äº§å“å¤§ç±» / è§„格型号走 up-action-sheet,选项由本组件自己拉取。
-->
<template>
  <view class="product-card">
    <view class="product-header">
      <text class="product-title">产品 {{ index + 1 }}</text>
      <up-icon name="trash"
               color="#ee0a24"
               size="18"
               @click="$emit('remove')"></up-icon>
    </view>
    <up-divider></up-divider>
    <view class="product-body">
      <view class="field-row"
            @click="openCategory">
        <text class="field-label">产品大类</text>
        <text :class="['field-value', row.productCategory ? '' : 'placeholder']">
          {{ row.productCategory || "请选择产品大类" }}
        </text>
        <up-icon name="arrow-right"
                 size="14"
                 color="#c0c4cc"></up-icon>
      </view>
      <view class="field-row"
            @click="openModel">
        <text class="field-label">规格型号</text>
        <text :class="['field-value', row.specificationModel ? '' : 'placeholder']">
          {{ row.specificationModel || "请选择规格型号" }}
        </text>
        <up-icon name="arrow-right"
                 size="14"
                 color="#c0c4cc"></up-icon>
      </view>
      <view class="field-row">
        <text class="field-label">单位</text>
        <u-input v-model="row.unit"
                 placeholder="请输入单位"
                 border="none"
                 inputAlign="right" />
      </view>
      <view class="field-row">
        <text class="field-label">数量</text>
        <u-input v-model="row.quantity"
                 type="number"
                 placeholder="请输入数量"
                 border="none"
                 inputAlign="right" />
      </view>
      <view class="field-row"
            @click="openTaxRate">
        <text class="field-label">税率(%)</text>
        <text :class="['field-value', row.taxRate ? '' : 'placeholder']">
          {{ taxRateText || "请选择税率" }}
        </text>
        <up-icon name="arrow-right"
                 size="14"
                 color="#c0c4cc"></up-icon>
      </view>
      <view class="field-row">
        <text class="field-label">含税单价</text>
        <u-input v-model="row.taxInclusiveUnitPrice"
                 type="number"
                 placeholder="请输入含税单价"
                 border="none"
                 inputAlign="right" />
      </view>
      <view class="field-row">
        <text class="field-label">含税总价</text>
        <text class="field-value readonly">{{ formatAmount(inclusiveTotal) }}</text>
      </view>
      <view class="field-row">
        <text class="field-label">不含税总价</text>
        <u-input v-model="row.taxExclusiveTotalPrice"
                 type="number"
                 placeholder="请输入不含税总价"
                 border="none"
                 inputAlign="right" />
      </view>
      <view class="field-row"
            @click="openInvoiceType">
        <text class="field-label">发票类型</text>
        <text :class="['field-value', row.invoiceType ? '' : 'placeholder']">
          {{ row.invoiceType || "请选择发票类型" }}
        </text>
        <up-icon name="arrow-right"
                 size="14"
                 color="#c0c4cc"></up-icon>
      </view>
    </view>
    <up-action-sheet :show="showCategorySheet"
                     title="选择产品大类"
                     :actions="categoryActions"
                     @select="onSelectCategory"
                     @close="showCategorySheet = false"></up-action-sheet>
    <up-action-sheet :show="showModelSheet"
                     title="选择规格型号"
                     :actions="modelActions"
                     @select="onSelectModel"
                     @close="showModelSheet = false"></up-action-sheet>
    <up-action-sheet :show="showTaxRateSheet"
                     title="选择税率"
                     :actions="taxRateActions"
                     @select="onSelectTaxRate"
                     @close="showTaxRateSheet = false"></up-action-sheet>
    <up-action-sheet :show="showInvoiceSheet"
                     title="选择发票类型"
                     :actions="INVOICE_TYPE_ACTIONS"
                     @select="onSelectInvoiceType"
                     @close="showInvoiceSheet = false"></up-action-sheet>
  </view>
</template>
<script setup>
  import { computed, onMounted, ref } from "vue";
  import { modelList, productTreeList } from "@/api/basicData/product";
  import { productInclusiveTotal } from "../_utils/mapper";
  const INVOICE_TYPE_ACTIONS = [
    { name: "增普票", value: "增普票" },
    { name: "增专票", value: "增专票" },
  ];
  const props = defineProps({
    row: {
      type: Object,
      required: true,
    },
    index: {
      type: Number,
      default: 0,
    },
    // ç¨ŽçŽ‡å­—å…¸ï¼ˆuseDict('tax_rate') çš„返回值)
    taxRateOptions: {
      type: Array,
      default: () => [],
    },
  });
  defineEmits(["remove"]);
  const categoryActions = ref([]);
  const modelActions = ref([]);
  const showCategorySheet = ref(false);
  const showModelSheet = ref(false);
  const showTaxRateSheet = ref(false);
  const showInvoiceSheet = ref(false);
  const inclusiveTotal = computed(() => productInclusiveTotal(props.row));
  const taxRateActions = computed(() =>
    (props.taxRateOptions || []).map(item => ({
      name: item.label,
      value: item.value,
    }))
  );
  const taxRateText = computed(() => {
    const matched = (props.taxRateOptions || []).find(
      item => String(item.value) === String(props.row.taxRate)
    );
    return matched ? matched.label : "";
  });
  const formatAmount = value => Number(value || 0).toFixed(2);
  const flattenTree = nodes => {
    const result = [];
    const walk = list => {
      (list || []).forEach(item => {
        if (item.children && item.children.length) {
          walk(item.children);
        } else {
          result.push({
            name: item.label || item.productName || "",
            value: item.id || item.value,
          });
        }
      });
    };
    walk(nodes);
    return result;
  };
  const loadCategories = () => {
    productTreeList()
      .then(res => {
        const nodes = Array.isArray(res) ? res : res?.data || [];
        categoryActions.value = flattenTree(nodes);
      })
      .catch(() => {
        categoryActions.value = [];
      });
  };
  const openCategory = () => {
    if (!categoryActions.value.length) loadCategories();
    showCategorySheet.value = true;
  };
  const openModel = () => {
    if (!props.row.productCategoryId) {
      uni.showToast({ title: "请先选择产品大类", icon: "none" });
      return;
    }
    if (!modelActions.value.length) {
      uni.showToast({ title: "暂无规格型号", icon: "none" });
      return;
    }
    showModelSheet.value = true;
  };
  const openTaxRate = () => {
    if (!taxRateActions.value.length) {
      uni.showToast({ title: "暂无税率选项", icon: "none" });
      return;
    }
    showTaxRateSheet.value = true;
  };
  const openInvoiceType = () => {
    showInvoiceSheet.value = true;
  };
  const onSelectCategory = action => {
    showCategorySheet.value = false;
    props.row.productCategoryId = action.value;
    props.row.productCategory = action.name;
    props.row.productModelId = "";
    props.row.specificationModel = "";
    props.row.unit = "";
    modelActions.value = [];
    modelList({ id: action.value })
      .then(res => {
        const rows = res?.data?.records || res?.data || res?.records || res || [];
        modelActions.value = (Array.isArray(rows) ? rows : []).map(item => ({
          name: item.model || item.specification || "",
          value: item.id,
          unit: item.unit,
        }));
      })
      .catch(() => {
        modelActions.value = [];
      });
  };
  const onSelectModel = action => {
    showModelSheet.value = false;
    props.row.productModelId = action.value;
    props.row.specificationModel = action.name;
    if (action.unit) props.row.unit = action.unit;
  };
  const onSelectTaxRate = action => {
    showTaxRateSheet.value = false;
    props.row.taxRate = action.value;
  };
  const onSelectInvoiceType = action => {
    showInvoiceSheet.value = false;
    props.row.invoiceType = action.value;
  };
  // æ‰“开型号面板前先把已有大类对应的型号捞回来(编辑回显场景)
  onMounted(() => {
    if (!props.row.productCategoryId) return;
    modelList({ id: props.row.productCategoryId })
      .then(res => {
        const rows = res?.data?.records || res?.data || res?.records || res || [];
        modelActions.value = (Array.isArray(rows) ? rows : []).map(item => ({
          name: item.model || item.specification || "",
          value: item.id,
          unit: item.unit,
        }));
        // åªå­˜äº†åž‹å·åç§°æ—¶åæŸ¥ id,保证选择器能命中
        if (!props.row.productModelId && props.row.specificationModel) {
          const matched = modelActions.value.find(
            item => item.name === props.row.specificationModel
          );
          if (matched) props.row.productModelId = matched.value;
        }
      })
      .catch(() => {});
  });
</script>
<style scoped lang="scss">
  .product-card {
    background: #fff;
    border-radius: 12px;
    padding: 0 12px 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .product-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 0;
  }
  .product-title {
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
  }
  .product-body {
    padding-top: 4px;
  }
  .field-row {
    display: flex;
    align-items: center;
    min-height: 44px;
    border-bottom: 1px solid #f5f6fa;
  }
  .field-row:last-child {
    border-bottom: none;
  }
  .field-label {
    width: 96px;
    flex-shrink: 0;
    font-size: 14px;
    color: #606266;
  }
  .field-value {
    flex: 1;
    min-width: 0;
    font-size: 14px;
    color: #303133;
    text-align: right;
    word-break: break-all;
  }
  .field-value.placeholder {
    color: #c0c4cc;
  }
  .field-value.readonly {
    color: #909399;
  }
</style>
src/pages/projectManagement/project/_components/TeamRow.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,148 @@
<!--
  é¡¹ç›®å›¢é˜Ÿå­è¡¨çš„一行
  æˆå‘˜ / è§’色下拉由父级统一拉取后传入(多个行共用一份选项,不重复请求)。
  æäº¤æ—¶åŽç«¯ä¸åæŸ¥ userName / userRoleName,所以选中后要把 label ä¸€èµ·è®°åœ¨ row ä¸Šã€‚
-->
<template>
  <view class="team-card">
    <view class="team-header">
      <text class="team-title">成员 {{ index + 1 }}</text>
      <up-icon name="trash"
               color="#ee0a24"
               size="18"
               @click="$emit('remove')"></up-icon>
    </view>
    <up-divider></up-divider>
    <view class="team-body">
      <view class="field-row">
        <text class="field-label">姓名</text>
        <FormPicker v-model="row.memberId"
                    type="select"
                    title="选择项目成员"
                    :options="userOptions"
                    placeholder="请选择成员"
                    @change="onMemberChange" />
      </view>
      <view class="field-row">
        <text class="field-label">项目组角色</text>
        <FormPicker v-model="row.roleId"
                    type="select"
                    title="选择项目组角色"
                    :options="roleOptions"
                    placeholder="请选择角色"
                    @change="onRoleChange" />
      </view>
      <view class="field-row">
        <text class="field-label">进入日期</text>
        <FormPicker v-model="row.enterDate"
                    type="date"
                    placeholder="请选择进入日期" />
      </view>
      <view class="field-row">
        <text class="field-label">离开日期</text>
        <FormPicker v-model="row.leaveDate"
                    type="date"
                    placeholder="请选择离开日期" />
      </view>
      <view class="field-row">
        <text class="field-label">联系方式</text>
        <u-input v-model="row.phone"
                 placeholder="请输入联系方式"
                 border="none"
                 inputAlign="right" />
      </view>
      <view class="field-row">
        <text class="field-label">备注</text>
        <u-input v-model="row.remark"
                 placeholder="请输入备注"
                 border="none"
                 inputAlign="right" />
      </view>
    </view>
  </view>
</template>
<script setup>
  import FormPicker from "@/components/FormPicker.vue";
  const props = defineProps({
    row: {
      type: Object,
      required: true,
    },
    index: {
      type: Number,
      default: 0,
    },
    userOptions: {
      type: Array,
      default: () => [],
    },
    roleOptions: {
      type: Array,
      default: () => [],
    },
  });
  defineEmits(["remove"]);
  // é€‰ä¸­åŽæŠŠ label è®°åˆ°è¡Œä¸Šï¼Œæäº¤æ—¶éš payload ä¸€èµ·å‘给后端
  const onMemberChange = (value, item) => {
    props.row.memberName = item?.name || props.row.memberName || "";
  };
  const onRoleChange = (value, item) => {
    props.row.roleName = item?.name || props.row.roleName || "";
  };
</script>
<style scoped lang="scss">
  .team-card {
    background: #fff;
    border-radius: 12px;
    padding: 0 12px 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .team-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 0;
  }
  .team-title {
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
  }
  .team-body {
    padding-top: 4px;
  }
  .field-row {
    display: flex;
    align-items: center;
    min-height: 44px;
    border-bottom: 1px solid #f5f6fa;
  }
  .field-row:last-child {
    border-bottom: none;
  }
  .field-label {
    width: 96px;
    flex-shrink: 0;
    font-size: 14px;
    color: #606266;
  }
</style>
src/pages/projectManagement/project/_utils/dict.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
/**
 * å­—典渲染小工具
 *
 * é¡¹ç›®ç®¡ç†çš„「单据状态 / è®¡åˆ’状态 / å®¡æ ¸çŠ¶æ€ã€éƒ½æ¥è‡ªåŽç«¯å­—å…¸
 * ï¼ˆbill_status / plan_status / project_management),useDict æ‹¿åˆ°çš„项形如
 * { label, value, elTagType, elTagClass }。列表与详情都要把 value æ¸²æŸ“成
 * æ ‡ç­¾æ–‡æ¡ˆ + é¢œè‰²ï¼Œè¿™é‡Œæ”¶ä¸€å¤„。
 *
 * up-tag åªè®¤ info/primary/success/warning/error,后端 listClass å¯èƒ½æ˜¯
 * danger(Element Plus è¯è¡¨ï¼‰ï¼Œå¿…须转换,否则标签不显示颜色。
 */
const TAG_TYPE_MAP = {
  danger: "error",
  "": "info",
  default: "info",
};
/** å­—典值 â†’ æ ‡ç­¾æ–‡æ¡ˆï¼Œæ‰¾ä¸åˆ°è¿”回原值 */
export function dictLabel(options, value) {
  if (value === undefined || value === null || value === "") return "";
  const matched = (options || []).find(
    item => String(item.value) === String(value)
  );
  return matched ? matched.label : String(value);
}
/** å­—典值 â†’ up-tag å¯ç”¨çš„ type */
export function dictTagType(options, value) {
  const matched = (options || []).find(
    item => String(item.value) === String(value)
  );
  const raw = matched?.elTagType ?? "";
  return TAG_TYPE_MAP[raw] || raw || "info";
}
/** useDict çš„字典项 â†’ FormPicker / FilterPanel è®¤è¯†çš„下拉选项 */
export function toPickerOptions(options) {
  return (options || []).map(item => ({
    name: item.label,
    value: item.value,
  }));
}
src/pages/projectManagement/project/_utils/mapper.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,318 @@
/**
 * é¡¹ç›®ç®¡ç†å­—段映射
 *
 * åŽç«¯ projectManagement çš„字段名与业务含义差异很大,映射关系取自管理端
 * views/projectManagement/Management/components/formDia.vue ä¸Ž projectDetail.vue:
 *
 *   info å±‚            no=单据编号、title=项目名称、clientName=客户名称、
 *                      establishTime=立项日期、source=项目来源、managerName=立项人、
 *                      orderAmount=项目金额、reviewStatus=审核状态、
 *                      status=单据状态、stage=计划状态、planStartTime/planEndTime=计划起止
 *   contractInfo å±‚    name=联系人、sex='男'/'女'、lineaFissa=企业微信、wx=微信、
 *                      origineEtnica=地址、rappresentanteLegale=备注
 *   shippingAddress å±‚ consignee=收货人、contract=联系电话、address=地址
 *   teamList é¡¹        userId/userName、userRoleId/userRoleName、joinTime/departTime、
 *                      contact=联系方式
 *
 * åŒå‘转换全部收敛在这里,页面里不出现裸字段名。
 * è¯¦æƒ…返回:detail.info / detail.shippingAddress / detail.contractInfo /
 *          detail.salesLedgerProductList / detail.info.teamList / detail.info.attachmentList
 * æäº¤ç»“构:{ info, shippingAddress, contractInfo, salesLedgerProductList }
 */
export const GENDER_OPTIONS = [
  { name: "男", value: "1" },
  { name: "女", value: "2" },
];
let uidSeed = 1;
const nextUid = () => `p_${uidSeed++}`;
const toStr = value =>
  value === undefined || value === null ? "" : String(value);
// åŽç«¯å­˜çš„æ˜¯ä¸­æ–‡æ€§åˆ«ï¼Œè¡¨å•选的是 '1'/'2'
const genderToValue = sex => (sex === "男" ? "1" : sex === "女" ? "2" : "");
const genderFromValue = value =>
  value === "1" ? "男" : value === "2" ? "女" : "";
// å­—典值后端可能返回数字,选择器按字符串比对
const dictValue = value =>
  value === undefined || value === null || value === "" ? "" : String(value);
const idOrNull = value =>
  value === undefined || value === null || value === "" ? null : value;
// æ”¶è´§åœ°å€ã€äº§å“å­è¡¨éƒ½å¯èƒ½ä¸ºç©ºå¯¹è±¡æˆ–缺失,统一取数组
const asArray = value => (Array.isArray(value) ? value : []);
/** åˆ—表行:列表接口字段 â†’ å±•示字段 */
export function toListRow(record = {}) {
  return {
    id: record.id,
    billNo: record.no ?? record.billNo ?? "",
    projectName: record.title ?? record.projectName ?? "",
    billStatus: dictValue(record.billStatus ?? record.status),
    auditStatus: dictValue(record.reviewStatus ?? record.auditStatus),
    projectStage: dictValue(record.stage ?? record.projectStage),
    customerName: record.clientName ?? record.customerName ?? "",
    setupDate: record.establishTime ?? record.setupDate ?? "",
    projectSource: record.source ?? record.projectSource ?? "",
    projectClassification:
      record.departmentName ?? record.projectClassification ?? "",
    projectAmount: record.orderAmount,
    salesContractCount: record.salesContractCount,
    raw: record,
  };
}
/** è¯¦æƒ…接口返回 â†’ è¯¦æƒ…页各分区 */
export function toDetail(detail = {}) {
  const info = detail?.info || {};
  const shippingAddress = detail?.shippingAddress || {};
  const contractInfo = detail?.contractInfo || {};
  const rawPhase =
    detail?.phaseList ||
    detail?.projectPhaseList ||
    detail?.projectStageList ||
    info?.phaseList ||
    info?.projectPhaseList;
  return {
    info,
    shippingAddress: {
      consignee: shippingAddress.consignee ?? "",
      contract: shippingAddress.contract ?? "",
      address: shippingAddress.address ?? "",
    },
    contractInfo: {
      name: contractInfo.name ?? "",
      sex: contractInfo.sex ?? "",
      birthday: contractInfo.birthday ?? "",
      department: contractInfo.department ?? "",
      job: contractInfo.job ?? "",
      phoneNumber: contractInfo.phoneNumber ?? "",
      email: contractInfo.email ?? "",
      qq: contractInfo.qq ?? "",
      wx: contractInfo.wx ?? "",
      // åŽç«¯è¿™ä¸¤ä¸ªå­—段名与业务含义完全无关,UI ä¸ŠæŒ‰ä¸šåŠ¡å«ä¹‰å±•ç¤º
      workWechat: contractInfo.lineaFissa ?? "",
      address: contractInfo.origineEtnica ?? "",
      remark: contractInfo.rappresentanteLegale ?? "",
    },
    productList: asArray(detail?.salesLedgerProductList),
    teamList: asArray(info.teamList),
    attachments: asArray(info.attachmentList).map(a => ({
      id: a.id ?? a.fileId,
      name: a.fileName ?? a.name ?? "",
      url: a.url ?? a.fileUrl ?? a.path ?? "",
    })),
    // è¡¨å•里没有可编辑的阶段子表(后端 /save ä¸åƒé˜¶æ®µï¼‰ï¼Œè¿™é‡Œåªç»™è¯¦æƒ…页只读展示用
    phaseList: asArray(rawPhase),
  };
}
/** è¯¦æƒ…接口返回 â†’ è¡¨å•(含子表) */
export function toForm(detail = {}) {
  const d = toDetail(detail);
  const info = d.info;
  const contractInfo = detail?.contractInfo || {};
  const shippingAddress = detail?.shippingAddress || {};
  const form = {
    id: info.id,
    billNo: info.no ?? "",
    projectName: info.title ?? "",
    clientId: info.clientId ?? "",
    customerName: info.clientName ?? "",
    parentProjectId: info.projectManagementInfoParentId ?? "",
    parentProjectName: info.projectManagementInfoParentName ?? "",
    projectManagementPlanId: info.projectManagementPlanId ?? "",
    projectSource: info.source ?? "",
    managerId: info.managerId ?? "",
    creatorName: info.managerName ?? "",
    salesmanId: info.salesmanId ?? "",
    salesmanName: info.salesmanName ?? "",
    departmentId: info.departmentId ?? "",
    departmentName: info.departmentName ?? "",
    setupDate: info.establishTime ?? "",
    planStartDate: info.planStartTime ?? "",
    planEndDate: info.planEndTime ?? "",
    actualStartDate: info.actualStartTime ?? "",
    actualEndDate: info.actualEndTime ?? "",
    orderDate: info.orderDate ?? "",
    projectAmount: info.orderAmount ?? "",
    billStatus: dictValue(info.status),
    auditStatus: dictValue(info.reviewStatus),
    projectStage: dictValue(info.stage ?? info.projectStage),
    remark: info.remark ?? "",
    contactName: contractInfo.name ?? "",
    contactGender: genderToValue(contractInfo.sex),
    contactBirthday: contractInfo.birthday ?? "",
    contactDept: contractInfo.department ?? "",
    contactJob: contractInfo.job ?? "",
    contactMobile: contractInfo.phoneNumber ?? "",
    contactEmail: contractInfo.email ?? "",
    contactQq: contractInfo.qq ?? "",
    contactWechat: contractInfo.wx ?? "",
    contactWorkWechat: contractInfo.lineaFissa ?? "",
    contactAddress: contractInfo.origineEtnica ?? "",
    contactRemark: contractInfo.rappresentanteLegale ?? "",
  };
  // æ”¶è´§åœ°å€åŽç«¯åªå­˜ä¸€æ¡ï¼Œè¿™é‡Œç”¨å•行编辑,提交时取第一行
  const addressList = shippingAddress?.address
    ? [
        {
          receiver: shippingAddress.consignee ?? "",
          phone: shippingAddress.contract ?? "",
          address: shippingAddress.address ?? "",
        },
      ]
    : [];
  const teamList = d.teamList.map(t => ({
    memberId: t.userId ?? "",
    memberName: t.userName ?? "",
    roleId: t.userRoleId ?? "",
    roleName: t.userRoleName ?? "",
    enterDate: t.joinTime ?? "",
    leaveDate: t.departTime ?? "",
    phone: t.contact ?? "",
    remark: t.remark ?? "",
  }));
  const productList = d.productList.map(p => ({
    uid: nextUid(),
    productCategoryId: p.productCategoryId ?? "",
    productCategory: p.productCategory ?? "",
    productModelId: p.productModelId ?? "",
    specificationModel: p.specificationModel ?? "",
    unit: p.unit ?? "",
    quantity: p.quantity ?? "",
    taxRate: p.taxRate ?? "",
    taxInclusiveUnitPrice: p.taxInclusiveUnitPrice ?? "",
    taxExclusiveTotalPrice: p.taxExclusiveTotalPrice ?? "",
    invoiceType: p.invoiceType ?? "",
  }));
  return { form, teamList, addressList, productList, attachments: d.attachments };
}
/** è¡¨å• â†’ æäº¤ç»“æž„ */
export function toPayload({
  form,
  teamList,
  addressList,
  productList,
  attachmentIds,
}) {
  const shippingRow = asArray(addressList)[0] || {};
  const info = {
    id: form.id ?? null,
    no: form.billNo,
    title: form.projectName,
    clientId: idOrNull(form.clientId),
    clientName: form.customerName,
    projectManagementInfoParentId: idOrNull(form.parentProjectId),
    projectManagementPlanId: idOrNull(form.projectManagementPlanId),
    establishTime: form.setupDate,
    source: form.projectSource,
    managerId: idOrNull(form.managerId),
    managerName: form.creatorName,
    salesmanId: idOrNull(form.salesmanId),
    salesmanName: form.salesmanName || "",
    departmentId: idOrNull(form.departmentId),
    departmentName: form.departmentName || "",
    planStartTime: form.planStartDate,
    planEndTime: form.planEndDate,
    actualStartTime: form.actualStartDate,
    actualEndTime: form.actualEndDate,
    orderDate: form.orderDate,
    orderAmount: Number(form.projectAmount) || 0,
    status: form.billStatus === "" ? null : Number(form.billStatus),
    reviewStatus: form.auditStatus === "" ? null : Number(form.auditStatus),
    stage: form.projectStage === "" ? null : Number(form.projectStage),
    remark: form.remark,
    attachmentIds: asArray(attachmentIds),
    // userName / userRoleName æ˜¯ä¸‹æ‹‰ label,后端不反查,必须前端带上
    teamList: asArray(teamList).map(t => ({
      userId: t.memberId,
      userName: t.memberName,
      userRoleId: t.roleId,
      userRoleName: t.roleName,
      joinTime: t.enterDate,
      departTime: t.leaveDate,
      contact: t.phone,
      remark: t.remark,
    })),
  };
  const shippingAddress = {
    id: undefined,
    consignee: shippingRow.receiver,
    contract: shippingRow.phone,
    address: shippingRow.address,
  };
  const contractInfo = {
    id: undefined,
    name: form.contactName,
    sex: genderFromValue(form.contactGender),
    birthday: form.contactBirthday,
    department: form.contactDept,
    job: form.contactJob,
    phoneNumber: form.contactMobile,
    email: form.contactEmail,
    qq: form.contactQq,
    wx: form.contactWechat,
    lineaFissa: form.contactWorkWechat,
    origineEtnica: form.contactAddress,
    rappresentanteLegale: form.contactRemark,
  };
  return {
    info,
    shippingAddress,
    contractInfo,
    salesLedgerProductList: asArray(productList).map(p => ({
      productCategoryId: p.productCategoryId,
      productCategory: p.productCategory,
      productModelId: p.productModelId,
      specificationModel: p.specificationModel,
      unit: p.unit,
      quantity: Number(p.quantity) || 0,
      taxRate: p.taxRate,
      taxInclusiveUnitPrice: Number(p.taxInclusiveUnitPrice) || 0,
      taxInclusiveTotalPrice: productInclusiveTotal(p),
      taxExclusiveTotalPrice: Number(p.taxExclusiveTotalPrice) || 0,
      invoiceType: p.invoiceType,
    })),
  };
}
/** å«ç¨Žæ€»ä»· = å«ç¨Žå•ä»· Ã— æ•°é‡ */
export function productInclusiveTotal(row) {
  const total =
    Number(row?.taxInclusiveUnitPrice || 0) * Number(row?.quantity || 0);
  return Number(total.toFixed(2));
}
/** è¯¦æƒ…页展示用的空产品行 */
export function createEmptyProduct() {
  return {
    uid: nextUid(),
    productCategoryId: "",
    productCategory: "",
    productModelId: "",
    specificationModel: "",
    unit: "",
    quantity: "",
    taxRate: "",
    taxInclusiveUnitPrice: "",
    taxExclusiveTotalPrice: "",
    invoiceType: "",
  };
}
src/pages/projectManagement/project/_utils/stage.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,131 @@
/**
 * é¡¹ç›®é˜¶æ®µ / è¿›åº¦æ±‡æŠ¥å…±ç”¨é€»è¾‘
 *
 * é˜¶æ®µæ•°æ®æœ‰ä¸¤ä¸ªæ¥æºï¼Œå«ä¹‰ä¸åŒï¼Œåˆ«æ··ï¼š
 *   listPlan()                                  â†’ é¡¹ç›®ç±»åž‹ä¸‹çš„「计划节点」(planNodeList),
 *                                                 æ˜¯æ¨¡æ¿ï¼Œå†³å®šäº†é˜¶æ®µè¿›åº¦æ¡ä¸Žã€Œè¿›åº¦æ±‡æŠ¥ã€çš„可选项
 *   listStage(projectId)                        â†’ è¯¥é¡¹ç›®å·²æ±‡æŠ¥è¿‡çš„阶段记录,详情页「项目阶段」Tab å±•示
 *
 * saveStage çš„入参字段名与表单字段名不一致(表单用 phaseName/planDays,
 * æŽ¥å£ç”¨ projectManagementPlanNodeId/estimatedDuration),转换也收敛在这里。
 */
import { listPlan } from "@/api/projectManagement/projectType";
import { listStage } from "@/api/projectManagement/project";
import { unwrapList } from "@/utils/list";
/** å–某个项目类型下的计划节点列表 */
export async function fetchPlanNodeList(planId) {
  if (planId === undefined || planId === null || planId === "") return [];
  try {
    const res = await listPlan({ current: 1, size: 999 });
    const records = unwrapList(res);
    const plan = records.find(p => String(p.id) === String(planId)) || {};
    const nodes = Array.isArray(plan.planNodeList) ? plan.planNodeList : [];
    // è¿›åº¦æ¡ä¸Žä¸‹æ‹‰éƒ½æŒ‰ sort æŽ’序,后端不保证顺序
    return [...nodes].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0));
  } catch (error) {
    return [];
  }
}
/** è®¡åˆ’节点 â†’ ä¸‹æ‹‰é€‰é¡¹ï¼ˆåˆ—表页与进度汇报共用一份 label è§„则) */
export function planNodeOptions(nodes) {
  return (nodes || [])
    .map(node => ({
      name: node.name || node.workContent || "",
      value: node.id,
      estimatedDuration: node.estimatedDuration,
    }))
    .filter(item => item.value !== undefined && item.value !== null);
}
/** è®¡åˆ’节点 id â†’ é˜¶æ®µåç§°ï¼Œè¯¦æƒ…页「项目阶段」Tab é‡ŒåŽç«¯åªå›ž id,需要前端反查 */
export function stageNameFromNodes(nodes, nodeId) {
  const matched = (nodes || []).find(n => String(n.id) === String(nodeId));
  return matched?.name || matched?.workContent || "";
}
/**
 * å–项目的已汇报阶段
 * è¿”回结构后端有 data.data / data.records ç­‰å¤šç§å½¢æ€ï¼Œç”¨ unwrapList å…œåº•
 */
export async function fetchProjectStages(projectId) {
  if (!projectId) return [];
  try {
    const res = await listStage(projectId);
    const inner = res?.data?.data ?? res?.data ?? res;
    return unwrapList({ data: inner });
  } catch (error) {
    return [];
  }
}
/**
 * æ ¹æ®å½“前计划状态推算默认选中的计划节点
 * åŽç«¯ stage å­˜çš„æ˜¯å­—典值,可能命中节点 id,也可能是序号,都兜一下
 */
export function defaultPlanNodeId(stageValue, nodes) {
  const list = Array.isArray(nodes) ? nodes : [];
  if (list.length === 0) return "";
  const direct = list.find(n => String(n.id) === String(stageValue));
  if (direct) return direct.id;
  const index = Number(stageValue);
  if (Number.isFinite(index)) {
    const byIndex = list[index - 1] || list[index] || list[0];
    if (byIndex) return byIndex.id;
  }
  return list[0].id;
}
/**
 * è¿›åº¦æ±‡æŠ¥è¡¨å• â†’ saveStage å…¥å‚
 * @param {object} options
 * @param {string|number} options.projectId
 * @param {object} options.projectInfo  é¡¹ç›®è¯¦æƒ…接口的 info èŠ‚ç‚¹
 * @param {Array}  options.planNodes    fetchPlanNodeList çš„结果
 * @param {object} options.form         è¿›åº¦æ±‡æŠ¥è¡¨å•
 * @param {Array}  options.attachments  CommonUpload çš„æ–‡ä»¶å¯¹è±¡æ•°ç»„
 * @param {string} options.leaderId     å½“前登录用户 id
 * @param {string} options.leaderName   å½“前登录用户昵称
 */
export function buildStagePayload({
  projectId,
  projectInfo,
  planNodes,
  form,
  attachments,
  leaderId,
  leaderName,
}) {
  const info = projectInfo || {};
  const node =
    (planNodes || []).find(n => String(n.id) === String(form.planNodeId)) || {};
  const description = form.remark
    ? `${form.reportDate || ""} ${form.remark}`.trim()
    : `${form.reportDate || ""} è¿›åº¦æ±‡æŠ¥`.trim();
  // åŽç«¯åªå­˜ä¸€ä¸ª progress。累计进度填了就优先用它,留空才退回完成进度 â€”—
  // ç”¨ç©ºå€¼åˆ¤æ–­è€Œä¸æ˜¯ `??`,否则被初始化成 "0" çš„累计进度会把完成进度吞掉。
  const totalProgress = form.totalProgress;
  const progress =
    totalProgress === "" || totalProgress === null || totalProgress === undefined
      ? Number(form.completionProgress || 0) || 0
      : Number(totalProgress) || 0;
  return {
    id: null,
    projectManagementPlanNodeId: form.planNodeId,
    projectManagementInfoId: projectId,
    description,
    // è´Ÿè´£äººåŽç«¯ä¸åæŸ¥ï¼Œå‰ç«¯å¸¦å½“前登录人;取不到再退回项目立项人
    actualLeaderId: leaderId || info.managerId || null,
    actualLeaderName: leaderName || info.managerName || "",
    estimatedDuration: Number(node.estimatedDuration ?? 0) || 0,
    planStartTime: form.planStartTime || info.planStartTime || null,
    planEndTime: form.planEndTime || info.planEndTime || null,
    actualStartTime: form.actualStartTime || null,
    actualEndTime: form.actualEndTime || null,
    progress,
    storageBlobDTOs: Array.isArray(attachments) ? attachments : [],
  };
}
src/pages/projectManagement/project/archive.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
<!--
  æ–‡æ¡£å½’æ¡£
  æ•°æ®æ¥è‡ª getProjectAttachments(id),返回按模块分组的附件:
    [{ moduleName, files: [{ name, url }] }]
  ï¼ˆå¯¹é½ç®¡ç†ç«¯ components/ArchiveDialog.vue çš„ res.data ç»“构)
  é™„件下载复用 utils/file.js çš„ openFile:request.ts æœªé€ä¼  responseType,
  åªèƒ½ downloadFile åˆ°ä¸´æ—¶æ–‡ä»¶å† openDocument。
-->
<template>
  <view class="project-archive">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view v-if="loading"
          class="state-text">
      <text>加载中...</text>
    </view>
    <view v-else-if="groups.length === 0"
          class="state-text">
      <text>暂无归档附件</text>
    </view>
    <view v-else
          class="archive-list">
      <view v-for="(group, index) in groups"
            :key="index"
            class="archive-group">
        <view class="group-header"
              @click="toggleGroup(index)">
          <text class="group-title">
            {{ group.moduleName || "未命名模块" }}({{ group.files.length }})
          </text>
          <up-icon :name="isOpen(index) ? 'arrow-up' : 'arrow-down'"
                   size="16"
                   color="#909399"></up-icon>
        </view>
        <view v-if="isOpen(index)"
              class="file-list">
          <view v-for="(file, fileIndex) in group.files"
                :key="fileIndex"
                class="file-item"
                @click="openFile(file.url)">
            <up-icon name="attach"
                     size="18"
                     color="#5b8ff9"></up-icon>
            <text class="file-name">{{ file.name || "未命名文件" }}</text>
            <up-icon name="download"
                     size="18"
                     color="#909399"></up-icon>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { openFile } from "@/utils/file";
  import { getProjectAttachments } from "@/api/projectManagement/project";
  const loading = ref(true);
  const projectId = ref("");
  const projectName = ref("");
  const groups = ref([]);
  const openIndex = ref([]);
  const pageTitle = computed(() =>
    projectName.value ? `文档归档 - ${projectName.value}` : "文档归档"
  );
  const goBack = () => uni.navigateBack();
  const isOpen = index => openIndex.value.includes(index);
  const toggleGroup = index => {
    openIndex.value = isOpen(index)
      ? openIndex.value.filter(item => item !== index)
      : [...openIndex.value, index];
  };
  // åŽç«¯å¯èƒ½å›ž files ä¸º null,统一成数组,避免模板里取 length æŠ¥é”™
  const normalize = list =>
    (Array.isArray(list) ? list : []).map(group => ({
      moduleName: group?.moduleName ?? "",
      files: (Array.isArray(group?.files) ? group.files : []).map(file => ({
        name: file?.name ?? "",
        url: file?.url ?? "",
      })),
    }));
  const loadAttachments = async () => {
    try {
      const res = await getProjectAttachments(projectId.value);
      const raw = res?.data?.data ?? res?.data ?? res;
      groups.value = normalize(raw);
      // å…¨éƒ¨åˆ†ç»„默认展开,与管理端 ArchiveDialog çš„ activeNames è¡Œä¸ºä¸€è‡´
      openIndex.value = groups.value.map((_, index) => index);
    } catch (error) {
      groups.value = [];
      uni.showToast({ title: "获取归档附件失败", icon: "none" });
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    projectId.value = options?.id || "";
    projectName.value = options?.name ? decodeURIComponent(options.name) : "";
    if (projectId.value) {
      loadAttachments();
    } else {
      loading.value = false;
    }
  });
</script>
<style scoped lang="scss">
  .project-archive {
    min-height: 100vh;
    background: #f5f6fa;
  }
  .state-text {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 80px 0;
    font-size: 14px;
    color: #909399;
  }
  .archive-list {
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .archive-group {
    background: #ffffff;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .group-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 16px;
    background: #f8fbff;
  }
  .group-title {
    font-size: 15px;
    font-weight: 600;
    color: #22324d;
  }
  .file-list {
    display: flex;
    flex-direction: column;
  }
  .file-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 12px 16px;
    border-top: 1px solid #f0f2f5;
  }
  .file-name {
    flex: 1;
    font-size: 14px;
    color: #606266;
    word-break: break-all;
  }
</style>
src/pages/projectManagement/project/config.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,150 @@
<!--
  ä¸šåŠ¡å‚æ•°è®¾ç½®ï¼ˆå¯¹é½ç®¡ç†ç«¯ views/projectManagement/config/index.vue)
  ç›®å‰åªæœ‰ä¸€ä¸ªå‚数「合同原件约定回传提前提醒天数」,用于销售台账里
  ã€ŒåˆåŒåŽŸä»¶çº¦å®šå›žä¼ æ—¥æœŸã€çš„ä¸´æœŸé¢„è­¦ï¼šdays = å›žä¼ æ—¥æœŸ - ä»Šå¤©ï¼Œdays <= N æŠ¥è­¦ã€‚
  åŽç«¯ GET /projectManagement/config è¿”回 { id, contractReturnReminderDays },
  PUT åŒåœ°å€æ•´ä½“覆盖,所以 id å¿…须原样带回去。
-->
<template>
  <view class="project-config">
    <PageHeader title="业务参数设置"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="200"
               input-align="right"
               error-message-align="right">
        <u-cell-group class="form-section">
          <up-form-item label="合同原件约定回传提前提醒天数"
                        prop="contractReturnReminderDays"
                        required>
            <up-input v-model="form.contractReturnReminderDays"
                      type="number"
                      placeholder="请输入天数" />
          </up-form-item>
        </u-cell-group>
      </up-form>
      <view class="tip-text">
        <text>示例:填 3 è¡¨ç¤ºã€ŒåˆåŒåŽŸä»¶çº¦å®šå›žä¼ æ—¥æœŸã€è·ä»Šä¸è¶³ 3 å¤©æ—¶æç¤ºé¢„警。</text>
      </view>
    </view>
    <FooterButtons :loading="loading"
                   confirm-text="保存设置"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import {
    getProjectConfig,
    updateProjectConfig,
  } from "@/api/projectManagement/config";
  const MIN_DAYS = 1;
  const MAX_DAYS = 365;
  const DEFAULT_DAYS = 3;
  const formRef = ref();
  const loading = ref(false);
  const form = ref({
    id: undefined,
    contractReturnReminderDays: DEFAULT_DAYS,
  });
  const validateDays = (rule, value, callback) => {
    const days = Number(value);
    if (value === "" || value === null || value === undefined) {
      callback(new Error("提前提醒天数不能为空"));
      return;
    }
    if (!Number.isInteger(days) || days < MIN_DAYS || days > MAX_DAYS) {
      callback(new Error(`天数需为 ${MIN_DAYS}~${MAX_DAYS} çš„æ•´æ•°`));
      return;
    }
    callback();
  };
  const rules = {
    contractReturnReminderDays: [
      { required: true, validator: validateDays, trigger: "blur" },
    ],
  };
  const goBack = () => uni.navigateBack();
  const loadConfig = async () => {
    try {
      const res = await getProjectConfig();
      const data = res?.data?.data ?? res?.data ?? res;
      if (!data) return;
      form.value.id = data.id;
      form.value.contractReturnReminderDays =
        Number(data.contractReturnReminderDays) || DEFAULT_DAYS;
    } catch (error) {
      uni.showToast({ title: "获取配置失败", icon: "none" });
    }
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    loading.value = true;
    updateProjectConfig({
      id: form.value.id,
      contractReturnReminderDays: Number(form.value.contractReturnReminderDays),
    })
      .then(() => {
        uni.showToast({ title: "配置更新成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 300);
      })
      .catch(() => {
        uni.showToast({ title: "配置更新失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(() => {
    loadConfig();
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .project-config {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .tip-text {
    padding: 12px 4px;
    font-size: 13px;
    line-height: 1.6;
    color: #909399;
  }
</style>
src/pages/projectManagement/project/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,948 @@
<!--
  é¡¹ç›®è¯¦æƒ…
  å…­ä¸ª Tab:基础资料 / é¡¹ç›®é˜¶æ®µ / è¯‰è®¼ä¿¡æ¯ / ä¸­æ ‡ä¿¡æ¯ / é”€å”®åˆåŒ / å…¨æµç¨‹æ—¶é—´è½´ã€‚
  è¯‰è®¼ã€ä¸­æ ‡ã€é”€å”®åˆåŒæ²¡æœ‰ã€ŒæŒ‰é¡¹ç›®æŸ¥ã€çš„专用接口,管理端也是拉全量后按
  é¡¹ç›® id / é¡¹ç›®åç§°åŒ¹é…ï¼ˆè§ projectDetail.vue çš„ matchProject),这里照做。
  ã€Œå…¨æµç¨‹æ—¶é—´è½´ã€æ˜¯å‰ç«¯èšåˆï¼šé˜¶æ®µæ±‡æŠ¥ + è¯‰è®¼ç«‹æ¡ˆ + æ‹›æŠ•æ ‡ æŒ‰æ—¥æœŸå€’序拼接,
  åŽç«¯æ²¡æœ‰ timeline æŽ¥å£ï¼›å°†æ¥åŽç«¯æä¾›äº†åªéœ€æ¢æ•°æ®æºï¼ŒUI ä¸ç”¨åŠ¨ã€‚
-->
<template>
  <view class="project-detail">
    <PageHeader title="项目详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading">
      <view class="detail-card">
        <view class="hero">
          <text class="hero-title">{{ info.title || "-" }}</text>
          <up-tag :text="dictLabel(auditOptions, info.reviewStatus)"
                  :type="dictTagType(auditOptions, info.reviewStatus)"
                  size="mini"
                  shape="circle" />
        </view>
        <view class="hero-meta">
          <text class="hero-meta-item">单据编号:{{ info.no || "-" }}</text>
          <text class="hero-meta-item">项目金额:{{ formatAmount(info.orderAmount) }}</text>
        </view>
      </view>
      <view v-if="planNodes.length > 0"
            class="steps-card">
        <up-steps :current="activeStep"
                  direction="row"
                  activeColor="#2979ff"
                  inactiveColor="#c0c4cc">
          <up-steps-item v-for="(node, index) in planNodes"
                         :key="node.id || index"
                         :title="node.name || node.workContent || ''" />
        </up-steps>
      </view>
      <view class="tabs-wrap">
        <up-tabs v-model="activeTab"
                 :list="TABS"
                 :scrollable="true"
                 itemStyle="padding: 0 14px; height: 84rpx;" />
      </view>
      <!-- åŸºç¡€èµ„æ–™ -->
      <view v-if="activeTab === 0">
        <u-cell-group title="基本信息"
                      class="detail-section">
          <u-cell title="单据编号"
                  :value="info.no || '-'" />
          <u-cell title="项目名称"
                  :value="info.title || '-'" />
          <u-cell title="客户名称"
                  :value="info.clientName || '-'" />
          <u-cell title="父项目"
                  :value="detail.info.parentProjectName || '-'" />
          <u-cell title="项目类型"
                  :value="projectTypeLabel" />
          <u-cell title="立项日期"
                  :value="info.establishTime || '-'" />
          <u-cell title="项目来源"
                  :value="info.source || '-'" />
          <u-cell title="立项人"
                  :value="info.managerName || '-'" />
          <u-cell title="业务员"
                  :value="info.salesmanName || '-'" />
          <u-cell title="部门"
                  :value="info.departmentName || '-'" />
          <u-cell title="单据状态"
                  :value="dictLabel(billOptions, info.status) || '-'" />
          <u-cell title="审核状态"
                  :value="dictLabel(auditOptions, info.reviewStatus) || '-'" />
          <u-cell title="计划状态"
                  :value="dictLabel(stageOptions, info.stage) || '-'" />
          <u-cell title="预计工期(天)"
                  :value="estimatedDays === null ? '-' : String(estimatedDays)" />
          <u-cell title="计划开始日期"
                  :value="info.planStartTime || '-'" />
          <u-cell title="计划完成日期"
                  :value="info.planEndTime || '-'" />
          <u-cell title="实际开始日期"
                  :value="info.actualStartTime || '-'" />
          <u-cell title="实际完成日期"
                  :value="info.actualEndTime || '-'" />
          <u-cell title="订单日期"
                  :value="info.orderDate || '-'" />
          <u-cell title="项目金额"
                  :value="formatAmount(info.orderAmount)" />
          <u-cell title="备注"
                  :value="info.remark || '-'" />
        </u-cell-group>
        <u-cell-group title="产品信息"
                      class="detail-section">
          <view v-if="productList.length === 0"
                class="empty-text">
            <text>暂无产品信息</text>
          </view>
          <view v-for="(row, index) in productList"
                v-else
                :key="row.id || index"
                class="sub-card">
            <view class="sub-card-header">
              <text class="sub-card-title">{{ row.productCategory || "产品" }} {{ index + 1 }}</text>
              <text class="sub-card-amount">{{ formatAmount(row.taxInclusiveTotalPrice) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">规格型号</text>
              <text class="detail-value">{{ row.specificationModel || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">单位</text>
              <text class="detail-value">{{ row.unit || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">数量</text>
              <text class="detail-value">{{ row.quantity ?? "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">税率(%)</text>
              <text class="detail-value">{{ row.taxRate ?? "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">含税单价</text>
              <text class="detail-value">{{ formatAmount(row.taxInclusiveUnitPrice) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">含税总价</text>
              <text class="detail-value">{{ formatAmount(row.taxInclusiveTotalPrice) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">不含税总价</text>
              <text class="detail-value">{{ formatAmount(row.taxExclusiveTotalPrice) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">发票类型</text>
              <text class="detail-value">{{ row.invoiceType || "-" }}</text>
            </view>
          </view>
        </u-cell-group>
        <u-cell-group title="项目团队"
                      class="detail-section">
          <view v-if="teamList.length === 0"
                class="empty-text">
            <text>暂无项目成员</text>
          </view>
          <view v-for="(row, index) in teamList"
                v-else
                :key="row.id || index"
                class="sub-card">
            <view class="sub-card-header">
              <text class="sub-card-title">{{ row.userName || "-" }}</text>
              <text class="sub-card-amount">{{ row.userRoleName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">进入日期</text>
              <text class="detail-value">{{ row.joinTime || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">离开日期</text>
              <text class="detail-value">{{ row.departTime || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">联系方式</text>
              <text class="detail-value">{{ row.contact || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">备注</text>
              <text class="detail-value">{{ row.remark || "-" }}</text>
            </view>
          </view>
        </u-cell-group>
        <u-cell-group title="收货地址"
                      class="detail-section">
          <u-cell title="收货人"
                  :value="detail.shippingAddress.consignee || '-'" />
          <u-cell title="联系方式"
                  :value="detail.shippingAddress.contract || '-'" />
          <u-cell title="地址"
                  :value="detail.shippingAddress.address || '-'" />
        </u-cell-group>
        <u-cell-group title="联系信息"
                      class="detail-section">
          <u-cell title="联系人"
                  :value="detail.contractInfo.name || '-'" />
          <u-cell title="性别"
                  :value="detail.contractInfo.sex || '-'" />
          <u-cell title="生日"
                  :value="detail.contractInfo.birthday || '-'" />
          <u-cell title="部门"
                  :value="detail.contractInfo.department || '-'" />
          <u-cell title="职务"
                  :value="detail.contractInfo.job || '-'" />
          <u-cell title="手机号"
                  :value="detail.contractInfo.phoneNumber || '-'" />
          <u-cell title="邮箱"
                  :value="detail.contractInfo.email || '-'" />
          <u-cell title="QQ"
                  :value="detail.contractInfo.qq || '-'" />
          <u-cell title="微信号"
                  :value="detail.contractInfo.wx || '-'" />
          <u-cell title="企业微信"
                  :value="detail.contractInfo.workWechat || '-'" />
          <u-cell title="地址"
                  :value="detail.contractInfo.address || '-'" />
          <u-cell title="备注"
                  :value="detail.contractInfo.remark || '-'" />
        </u-cell-group>
        <u-cell-group title="附件"
                      class="detail-section">
          <view v-if="attachments.length === 0"
                class="empty-text">
            <text>暂无附件</text>
          </view>
          <view v-for="(file, index) in attachments"
                v-else
                :key="file.id || index"
                class="file-item"
                @click="previewFile(file)">
            <up-icon name="attach"
                     size="16"
                     color="#2979ff"></up-icon>
            <text class="file-name">{{ file.name || `附件${index + 1}` }}</text>
            <up-icon name="arrow-right"
                     size="14"
                     color="#c0c4cc"></up-icon>
          </view>
        </u-cell-group>
      </view>
      <!-- é¡¹ç›®é˜¶æ®µ -->
      <view v-if="activeTab === 1">
        <u-cell-group title="计划节点(项目类型模板)"
                      class="detail-section">
          <view v-if="planNodes.length === 0"
                class="empty-text">
            <text>该项目类型未配置计划节点</text>
          </view>
          <view v-for="(node, index) in planNodes"
                v-else
                :key="node.id || index"
                class="sub-card">
            <view class="sub-card-header">
              <text class="sub-card-title">{{ node.name || node.workContent || "-" }}</text>
              <text class="sub-card-amount">工期 {{ node.estimatedDuration ?? "-" }} å¤©</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">负责人</text>
              <text class="detail-value">{{ node.leaderName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">作业内容</text>
              <text class="detail-value">{{ node.workContent || "-" }}</text>
            </view>
          </view>
        </u-cell-group>
        <u-cell-group title="阶段汇报记录"
                      class="detail-section">
          <view v-if="stageList.length === 0"
                class="empty-text">
            <text>暂无阶段汇报</text>
          </view>
          <view v-for="(row, index) in stageList"
                v-else
                :key="row.id || index"
                class="sub-card">
            <view class="sub-card-header">
              <text class="sub-card-title">{{ row.stageName || "-" }}</text>
              <text class="sub-card-amount">{{ row.progress ?? 0 }}%</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">描述</text>
              <text class="detail-value">{{ row.description || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">实际负责人</text>
              <text class="detail-value">{{ row.actualLeaderName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">计划起止</text>
              <text class="detail-value">{{ row.planStartTime || "-" }} ~ {{ row.planEndTime || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">实际起止</text>
              <text class="detail-value">{{ row.actualStartTime || "-" }} ~ {{ row.actualEndTime || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">预计工期</text>
              <text class="detail-value">{{ row.estimatedDuration ?? "-" }} å¤©</text>
            </view>
          </view>
        </u-cell-group>
      </view>
      <!-- è¯‰è®¼ä¿¡æ¯ -->
      <view v-if="activeTab === 2">
        <view v-if="lawsuitList.length === 0"
              class="no-data">
          <text>暂无诉讼信息</text>
        </view>
        <view v-else
              class="list-wrap">
          <view v-for="row in lawsuitList"
                :key="row.id"
                class="list-card">
            <view class="list-card-header">
              <text class="list-card-title">{{ row.caseNo || "-" }}</text>
              <up-tag :text="row.lawsuitStatus || '-'"
                      :type="lawsuitTagType(row.lawsuitStatus)"
                      size="mini"
                      shape="circle" />
            </view>
            <view class="detail-row">
              <text class="detail-label">合同编号</text>
              <text class="detail-value">{{ row.contractNo || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">立案时间</text>
              <text class="detail-value">{{ formatDate(row.filingDate) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">开庭时间</text>
              <text class="detail-value">{{ formatDate(row.hearingDate) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">标的金额</text>
              <text class="detail-value">{{ formatAmount(row.claimAmount) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">承办律师</text>
              <text class="detail-value">{{ row.lawyer || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">判决摘要</text>
              <text class="detail-value">{{ row.judgmentSummary || "-" }}</text>
            </view>
          </view>
        </view>
      </view>
      <!-- ä¸­æ ‡ä¿¡æ¯ -->
      <view v-if="activeTab === 3">
        <view v-if="biddingList.length === 0"
              class="no-data">
          <text>暂无招投标信息</text>
        </view>
        <view v-else
              class="list-wrap">
          <view v-for="row in biddingList"
                :key="row.id"
                class="list-card">
            <view class="list-card-header">
              <text class="list-card-title">{{ row.bidDocName || "-" }}</text>
              <up-tag :text="row.bidResult || '-'"
                      :type="biddingTagType(row.bidResult)"
                      size="mini"
                      shape="circle" />
            </view>
            <view class="detail-row">
              <text class="detail-label">招标单位</text>
              <text class="detail-value">{{ row.tenderUnit || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">投标单位</text>
              <text class="detail-value">{{ row.bidUnit || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">招标日期</text>
              <text class="detail-value">{{ formatDate(row.tenderDate) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">投标日期</text>
              <text class="detail-value">{{ formatDate(row.bidDate) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">标书款</text>
              <text class="detail-value">{{ formatAmount(row.bidDocFee) }}({{ row.bidDocFeeStatus || "-" }})</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">投标保证金</text>
              <text class="detail-value">{{ formatAmount(row.bidDeposit) }}</text>
            </view>
          </view>
        </view>
      </view>
      <!-- é”€å”®åˆåŒ -->
      <view v-if="activeTab === 4">
        <view v-if="contractList.length === 0"
              class="no-data">
          <text>暂无销售合同</text>
        </view>
        <view v-else
              class="list-wrap">
          <view v-for="row in contractList"
                :key="row.id"
                class="list-card">
            <view class="list-card-header">
              <text class="list-card-title">{{ row.salesContractNo || "-" }}</text>
              <text class="list-card-meta">{{ formatDate(row.executionDate) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">客户名称</text>
              <text class="detail-value">{{ row.customerName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">业务员</text>
              <text class="detail-value">{{ row.salesman || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">合同金额</text>
              <text class="detail-value">{{ formatAmount(row.contractAmount) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">实际合同金额</text>
              <text class="detail-value">{{ formatAmount(row.netContractAmount) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">交付日期</text>
              <text class="detail-value">{{ formatDate(row.deliveryDate) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">录入人</text>
              <text class="detail-value">{{ row.entryPersonName || "-" }}</text>
            </view>
          </view>
        </view>
      </view>
      <!-- å…¨æµç¨‹æ—¶é—´è½´ï¼ˆå‰ç«¯èšåˆï¼ŒéžåŽç«¯æŽ¥å£ï¼‰ -->
      <view v-if="activeTab === 5">
        <view v-if="timeline.length === 0"
              class="no-data">
          <text>暂无流程记录</text>
        </view>
        <view v-else
              class="timeline-wrap">
          <up-steps :current="-1"
                    direction="column"
                    activeColor="#2979ff"
                    inactiveColor="#2979ff">
            <up-steps-item v-for="(item, index) in timeline"
                           :key="index"
                           :title="item.title"
                           :desc="item.desc" />
          </up-steps>
        </view>
      </view>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
    <view v-if="!loading"
          class="footer-btns">
      <up-button class="footer-btn"
                 text="返回"
                 @click="goBack" />
      <up-button class="footer-btn"
                 type="primary"
                 text="进度汇报"
                 @click="goProgress" />
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad, onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { useDict } from "@/utils/dict";
  import { unwrapList } from "@/utils/list";
  import { openFile } from "@/utils/file";
  import { parseDate } from "@/utils/warning";
  import { getProject } from "@/api/projectManagement/project";
  import { listPlan } from "@/api/projectManagement/projectType";
  import { lawsuitListPage } from "@/api/salesManagement/lawsuitManagement";
  import { biddingListPage } from "@/api/salesManagement/biddingManagement";
  import { ledgerListPage } from "@/api/salesManagement/salesLedger";
  import { toDetail } from "./_utils/mapper";
  import { dictLabel, dictTagType, toPickerOptions } from "./_utils/dict";
  import {
    fetchPlanNodeList,
    fetchProjectStages,
    stageNameFromNodes,
  } from "./_utils/stage";
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
  const TABS = [
    { name: "基础资料" },
    { name: "项目阶段" },
    { name: "诉讼信息" },
    { name: "中标信息" },
    { name: "销售合同" },
    { name: "全流程时间轴" },
  ];
  const LAWSUIT_TAG_TYPE = {
    ç«‹æ¡ˆä¸­: "warning",
    å®¡ç†ä¸­: "primary",
    å·²åˆ¤å†³: "success",
    å·²æ’¤è¯‰: "info",
    å·²å’Œè§£: "success",
  };
  const BIDDING_TAG_TYPE = {
    åºŸæ ‡: "info",
    æµæ ‡: "info",
    æœªä¸­æ ‡: "warning",
    ä¸­æ ‡å¾…出通知书: "primary",
    å·²å‡ºä¸­æ ‡é€šçŸ¥ä¹¦: "success",
    å·²ç­¾åˆåŒ: "success",
  };
  const { bill_status, project_management, plan_status } = useDict(
    "bill_status",
    "project_management",
    "plan_status"
  );
  const billOptions = computed(() => toPickerOptions(bill_status.value));
  const auditOptions = computed(() => toPickerOptions(project_management.value));
  const stageOptions = computed(() => toPickerOptions(plan_status.value));
  const loading = ref(true);
  const projectId = ref("");
  const activeTab = ref(0);
  const detail = ref({});
  const planNodes = ref([]);
  const stageList = ref([]);
  const projectTypeName = ref("");
  const lawsuitList = ref([]);
  const biddingList = ref([]);
  const contractList = ref([]);
  const info = computed(() => detail.value.info || {});
  const productList = computed(() => detail.value.productList || []);
  const teamList = computed(() => detail.value.teamList || []);
  const attachments = computed(() => detail.value.attachments || []);
  const projectTypeLabel = computed(
    () => projectTypeName.value || String(info.value.projectManagementPlanId ?? "-")
  );
  const estimatedDays = computed(() => {
    const raw = Number(info.value.estimatedDays);
    if (Number.isFinite(raw) && raw > 0) return raw;
    const start = parseDate(info.value.planStartTime);
    const end = parseDate(info.value.planEndTime);
    if (!start || !end || end < start) return null;
    return Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
  });
  // è®¡åˆ’节点已按 sort æŽ’序,计划状态按序号或节点 id å‘½ä¸­
  const activeStep = computed(() => {
    const nodes = planNodes.value;
    const stage = info.value.stage;
    if (!nodes.length) return 0;
    const byId = nodes.findIndex(n => String(n.id) === String(stage));
    if (byId >= 0) return byId;
    const byLabel = nodes.findIndex(
      n => String(n.name || n.workContent) === String(stage)
    );
    if (byLabel >= 0) return byLabel;
    const index = Number(stage);
    if (Number.isFinite(index) && index > 0) return Math.min(index - 1, nodes.length - 1);
    return 0;
  });
  // å…¨æµç¨‹æ—¶é—´è½´ï¼šé˜¶æ®µ + è¯‰è®¼ + æ‹›æŠ•æ ‡ æŒ‰æ—¥æœŸå€’序聚合(前端拼接,后端无 timeline æŽ¥å£ï¼‰
  const timeline = computed(() => {
    const events = [];
    stageList.value.forEach(row => {
      events.push({
        date: row.planStartTime || row.actualStartTime || "",
        title: `【项目阶段】${row.stageName || "阶段汇报"}`,
        desc: row.description || "",
      });
    });
    lawsuitList.value.forEach(row => {
      events.push({
        date: row.filingDate || "",
        title: `【诉讼】${row.caseNo || ""} ${row.lawsuitStatus || ""}`.trim(),
        desc: row.judgmentSummary || row.progressRemark || "",
      });
    });
    biddingList.value.forEach(row => {
      events.push({
        date: row.bidDate || "",
        title: `【招投标】${row.bidDocName || ""} ${row.bidResult || ""}`.trim(),
        desc: row.tenderUnit ? `招标单位:${row.tenderUnit}` : "",
      });
    });
    return events
      .filter(item => item.title)
      .sort((a, b) => String(b.date).localeCompare(String(a.date)));
  });
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.navigateTo({
      url: `/pages/projectManagement/project/edit?id=${projectId.value}`,
    });
  };
  const goProgress = () => {
    uni.navigateTo({
      url: `/pages/projectManagement/project/progress?id=${projectId.value}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const formatDate = value => (value ? String(value).substring(0, 10) : "-");
  const lawsuitTagType = status => LAWSUIT_TAG_TYPE[status] || "info";
  const biddingTagType = status => BIDDING_TAG_TYPE[status] || "info";
  const previewFile = file => openFile(file?.url || file?.previewURL);
  const loadProjectTypeName = async () => {
    const planId = info.value.projectManagementPlanId;
    if (planId === undefined || planId === null || planId === "") return;
    try {
      const res = await listPlan({ current: 1, size: 999 });
      const matched = unwrapList(res).find(
        item => String(item.id) === String(planId)
      );
      projectTypeName.value = matched?.name || "";
    } catch (error) {
      projectTypeName.value = "";
    }
  };
  // è¯‰è®¼ / æ‹›æŠ•标没有按项目查的接口,拉全量后按项目 id æˆ–名称匹配(对齐管理端 matchProject)
  const matchProject = (record, id, title) => {
    if (!record) return false;
    if (
      record.projectId !== undefined &&
      record.projectId !== null &&
      record.projectId !== ""
    ) {
      if (String(record.projectId) === String(id)) return true;
    }
    const name = String(record.projectName || "").trim();
    const target = String(title || "").trim();
    if (name && target) {
      return name === target || name.includes(target) || target.includes(name);
    }
    return false;
  };
  const loadRelated = async (id, title) => {
    if (!id) return;
    try {
      const res = await lawsuitListPage({ projectName: title, current: -1, size: -1 });
      let matched = unwrapList(res).filter(r => matchProject(r, id, title));
      if (matched.length === 0) {
        const all = await lawsuitListPage({ current: -1, size: -1 });
        matched = unwrapList(all).filter(r => matchProject(r, id, title));
      }
      lawsuitList.value = matched;
    } catch (error) {
      lawsuitList.value = [];
    }
    try {
      const res = await biddingListPage({ projectName: title, current: -1, size: -1 });
      let matched = unwrapList(res).filter(r => matchProject(r, id, title));
      if (matched.length === 0) {
        const all = await biddingListPage({ current: -1, size: -1 });
        matched = unwrapList(all).filter(r => matchProject(r, id, title));
      }
      biddingList.value = matched;
    } catch (error) {
      biddingList.value = [];
    }
    try {
      const res = await ledgerListPage({ projectId: id, current: -1, size: -1 });
      contractList.value = unwrapList(res).sort((a, b) =>
        String(b.executionDate || "").localeCompare(String(a.executionDate || ""))
      );
    } catch (error) {
      contractList.value = [];
    }
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      const res = await getProject(projectId.value);
      const raw = res?.data?.data ?? res?.data ?? res;
      detail.value = toDetail(raw || {});
      const id = info.value.id || projectId.value;
      const title = info.value.title || "";
      const [nodes, stages] = await Promise.all([
        fetchPlanNodeList(info.value.projectManagementPlanId),
        fetchProjectStages(id),
      ]);
      planNodes.value = nodes;
      stageList.value = stages.map(row => ({
        ...row,
        stageName: stageNameFromNodes(nodes, row.projectManagementPlanNodeId),
      }));
      await Promise.all([loadProjectTypeName(), loadRelated(id, title)]);
    } catch (error) {
      uni.showToast({ title: "获取项目详情失败", icon: "none" });
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    projectId.value = options?.id || "";
    if (!projectId.value) loading.value = false;
  });
  // ä»Žã€Œè¿›åº¦æ±‡æŠ¥ã€è¿”回时阶段列表会变,用 onShow é‡æ‹‰ï¼ˆonShow åœ¨ onLoad åŽä¹Ÿä¼šè§¦å‘)
  onShow(() => {
    if (projectId.value) loadDetail();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .project-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 90px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .hero-meta {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 12px 18px 0;
  }
  .hero-meta-item {
    font-size: 12px;
    color: #909399;
  }
  .steps-card {
    margin: 12px;
    padding: 16px 8px;
    border-radius: 12px;
    background: #ffffff;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .tabs-wrap {
    margin: 12px 12px 0;
    border-radius: 12px;
    overflow: hidden;
    background: #ffffff;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .detail-section {
    margin: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .sub-card {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f8;
  }
  .sub-card:last-child {
    border-bottom: none;
  }
  .sub-card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
  }
  .sub-card-title {
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
  }
  .sub-card-amount {
    font-size: 13px;
    color: #2979ff;
  }
  .list-wrap {
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .list-card {
    background: #ffffff;
    border-radius: 12px;
    padding: 14px 16px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .list-card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
  }
  .list-card-title {
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
    max-width: 70%;
    word-break: break-all;
  }
  .list-card-meta {
    font-size: 12px;
    color: #909399;
  }
  .timeline-wrap {
    margin: 12px;
    padding: 20px 16px;
    border-radius: 12px;
    background: #ffffff;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .file-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f8;
  }
  .file-item:last-child {
    border-bottom: none;
  }
  .file-name {
    flex: 1;
    font-size: 14px;
    color: #333;
    word-break: break-all;
  }
  .empty-text {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  .footer-btns {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    gap: 12px;
    padding: 12px 16px;
    padding-bottom: calc(12px + env(safe-area-inset-bottom));
    background: #ffffff;
    box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
    z-index: 1000;
  }
  .footer-btn {
    flex: 1;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/projectManagement/project/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,574 @@
<!--
  é¡¹ç›®æ–°å¢ž / ç¼–辑
  è¡¨å•分区对齐管理端 formDia.vue:基础资料 / äº§å“ä¿¡æ¯ / é¡¹ç›®å›¢é˜Ÿ / æ”¶è´§åœ°å€ / è”系信息 / é™„件。
  ç®¡ç†ç«¯è¿˜æœ‰ä¸€ä¸ªã€Œé¡¹ç›®é˜¶æ®µã€å­è¡¨ï¼Œæ•´å—是注释掉的且不在 /save çš„入参里,
  æ‰€ä»¥è¿™é‡Œä¹Ÿä¸åšå¯ç¼–辑阶段表(做了会静默丢数据);阶段通过详情页查看、通过「进度汇报」写入。
  åŸºç¡€èµ„料里「预计工期」在管理端可填但不在提交结构里(后端按计划起止日期派生),
  æ‰€ä»¥è¿™é‡Œåšæˆåªè¯»æ´¾ç”Ÿå€¼ï¼Œé¿å…ç”¨æˆ·å¡«äº†ä¸ç”Ÿæ•ˆã€‚
-->
<template>
  <view class="project-edit">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="基础资料"
                      class="form-section">
          <up-form-item v-if="projectId"
                        label="单据编号">
            <up-input :model-value="form.billNo || '系统生成'"
                      disabled />
          </up-form-item>
          <up-form-item label="项目名称"
                        prop="projectName"
                        required>
            <up-input v-model="form.projectName"
                      placeholder="请输入项目名称"
                      clearable />
          </up-form-item>
          <up-form-item label="客户名称"
                        prop="customerName">
            <up-input v-model="form.customerName"
                      placeholder="请输入客户名称"
                      clearable />
          </up-form-item>
          <up-form-item label="立项日期"
                        prop="setupDate">
            <FormPicker v-model="form.setupDate"
                        type="date"
                        placeholder="请选择立项日期" />
          </up-form-item>
          <up-form-item label="项目来源"
                        prop="projectSource">
            <up-input v-model="form.projectSource"
                      placeholder="请输入项目来源"
                      clearable />
          </up-form-item>
          <up-form-item label="立项人"
                        prop="creatorName">
            <up-input v-model="form.creatorName"
                      placeholder="请输入立项人"
                      clearable />
          </up-form-item>
          <up-form-item label="项目类型"
                        prop="projectManagementPlanId"
                        required>
            <FormPicker v-model="form.projectManagementPlanId"
                        type="select"
                        title="选择项目类型"
                        :options="projectTypeOptions"
                        placeholder="请选择项目类型" />
          </up-form-item>
          <up-form-item label="计划开始日期"
                        prop="planStartDate">
            <FormPicker v-model="form.planStartDate"
                        type="date"
                        placeholder="请选择计划开始日期" />
          </up-form-item>
          <up-form-item label="计划完成日期"
                        prop="planEndDate">
            <FormPicker v-model="form.planEndDate"
                        type="date"
                        placeholder="请选择计划完成日期" />
          </up-form-item>
          <up-form-item label="预计工期(天)">
            <up-input :model-value="estimatedDays === null ? '按计划日期自动计算' : String(estimatedDays)"
                      disabled />
          </up-form-item>
          <up-form-item label="项目金额"
                        prop="projectAmount">
            <up-input v-model="form.projectAmount"
                      type="number"
                      placeholder="请输入项目金额"
                      clearable />
          </up-form-item>
          <up-form-item label="审核状态"
                        prop="auditStatus">
            <FormPicker v-model="form.auditStatus"
                        type="select"
                        title="选择审核状态"
                        :options="auditOptions"
                        placeholder="请选择审核状态" />
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-textarea v-model="form.remark"
                         placeholder="请输入备注"
                         :maxlength="100"
                         count
                         auto-height />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="产品信息"
                      class="form-section">
          <view class="section-tools">
            <up-button type="primary"
                       size="small"
                       text="添加产品"
                       @click="addProduct" />
          </view>
          <view v-if="productList.length === 0"
                class="empty-text">
            <text>请至少添加一条产品信息</text>
          </view>
          <view v-else
                class="row-list">
            <ProductRow v-for="(row, index) in productList"
                        :key="row.uid"
                        :row="row"
                        :index="index"
                        :tax-rate-options="taxRateOptions"
                        @remove="removeProduct(index)" />
          </view>
        </u-cell-group>
        <u-cell-group title="项目团队"
                      class="form-section">
          <view class="section-tools">
            <up-button type="primary"
                       size="small"
                       text="添加成员"
                       @click="addTeamRow" />
          </view>
          <view v-if="teamList.length === 0"
                class="empty-text">
            <text>暂无项目成员</text>
          </view>
          <view v-else
                class="row-list">
            <TeamRow v-for="(row, index) in teamList"
                     :key="index"
                     :row="row"
                     :index="index"
                     :user-options="userOptions"
                     :role-options="roleOptions"
                     @remove="removeTeamRow(index)" />
          </view>
        </u-cell-group>
        <u-cell-group title="收货地址"
                      class="form-section">
          <view v-if="addressList.length === 0"
                class="section-tools">
            <up-button type="primary"
                       size="small"
                       text="添加地址"
                       @click="addAddressRow" />
          </view>
          <view v-else
                class="row-list">
            <AddressRow v-for="(row, index) in addressList"
                        :key="index"
                        :row="row" />
          </view>
        </u-cell-group>
        <u-cell-group title="联系信息"
                      class="form-section">
          <up-form-item label="联系人姓名"
                        prop="contactName">
            <up-input v-model="form.contactName"
                      placeholder="请输入联系人姓名"
                      clearable />
          </up-form-item>
          <up-form-item label="性别"
                        prop="contactGender">
            <FormPicker v-model="form.contactGender"
                        type="select"
                        title="选择性别"
                        :options="GENDER_OPTIONS"
                        placeholder="请选择性别" />
          </up-form-item>
          <up-form-item label="生日"
                        prop="contactBirthday">
            <FormPicker v-model="form.contactBirthday"
                        type="date"
                        placeholder="请选择生日" />
          </up-form-item>
          <up-form-item label="部门"
                        prop="contactDept">
            <up-input v-model="form.contactDept"
                      placeholder="请输入部门"
                      clearable />
          </up-form-item>
          <up-form-item label="职务"
                        prop="contactJob">
            <up-input v-model="form.contactJob"
                      placeholder="请输入职务"
                      clearable />
          </up-form-item>
          <up-form-item label="手机号码"
                        prop="contactMobile">
            <up-input v-model="form.contactMobile"
                      type="number"
                      placeholder="请输入手机号码"
                      clearable />
          </up-form-item>
          <up-form-item label="邮箱"
                        prop="contactEmail">
            <up-input v-model="form.contactEmail"
                      placeholder="请输入邮箱"
                      clearable />
          </up-form-item>
          <up-form-item label="QQ"
                        prop="contactQq">
            <up-input v-model="form.contactQq"
                      placeholder="请输入 QQ"
                      clearable />
          </up-form-item>
          <up-form-item label="微信号码"
                        prop="contactWechat">
            <up-input v-model="form.contactWechat"
                      placeholder="请输入微信号码"
                      clearable />
          </up-form-item>
          <up-form-item label="企业微信"
                        prop="contactWorkWechat">
            <up-input v-model="form.contactWorkWechat"
                      placeholder="请输入企业微信"
                      clearable />
          </up-form-item>
          <up-form-item label="地址"
                        prop="contactAddress">
            <up-input v-model="form.contactAddress"
                      placeholder="请输入地址"
                      clearable />
          </up-form-item>
          <up-form-item label="联系信息备注"
                        prop="contactRemark">
            <up-textarea v-model="form.contactRemark"
                         placeholder="请输入备注"
                         :maxlength="200"
                         count
                         auto-height />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="附件"
                      class="form-section">
          <view class="upload-block">
            <CommonUpload v-model="attachmentFiles"
                          accept="all" />
          </view>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import ProductRow from "./_components/ProductRow.vue";
  import TeamRow from "./_components/TeamRow.vue";
  import AddressRow from "./_components/AddressRow.vue";
  import { useDict } from "@/utils/dict";
  import { parseDate } from "@/utils/warning";
  import { unwrapList } from "@/utils/list";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { findRoleListPage } from "@/api/projectManagement/role";
  import { listPlan } from "@/api/projectManagement/projectType";
  import { getProject, saveProject } from "@/api/projectManagement/project";
  import {
    GENDER_OPTIONS,
    createEmptyProduct,
    toForm,
    toPayload,
  } from "./_utils/mapper";
  import { toPickerOptions } from "./_utils/dict";
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
  const { project_management, tax_rate } = useDict(
    "project_management",
    "tax_rate"
  );
  const auditOptions = computed(() => toPickerOptions(project_management.value));
  const taxRateOptions = computed(() =>
    (tax_rate.value || []).map(item => ({ label: item.label, value: item.value }))
  );
  const formRef = ref();
  const loading = ref(false);
  const projectId = ref("");
  const projectTypeOptions = ref([]);
  const userOptions = ref([]);
  const roleOptions = ref([]);
  const form = ref({
    id: undefined,
    billNo: "",
    projectName: "",
    clientId: "",
    customerName: "",
    parentProjectId: "",
    parentProjectName: "",
    projectManagementPlanId: "",
    projectSource: "",
    managerId: "",
    creatorName: "",
    salesmanId: "",
    salesmanName: "",
    departmentId: "",
    departmentName: "",
    setupDate: "",
    planStartDate: "",
    planEndDate: "",
    actualStartDate: "",
    actualEndDate: "",
    orderDate: "",
    projectAmount: "",
    billStatus: "",
    auditStatus: "",
    projectStage: "",
    remark: "",
    contactName: "",
    contactGender: "",
    contactBirthday: "",
    contactDept: "",
    contactJob: "",
    contactMobile: "",
    contactEmail: "",
    contactQq: "",
    contactWechat: "",
    contactWorkWechat: "",
    contactAddress: "",
    contactRemark: "",
  });
  const teamList = ref([]);
  const addressList = ref([]);
  const productList = ref([]);
  const attachmentFiles = ref([]);
  const rules = {
    projectName: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
    projectManagementPlanId: [
      { required: true, message: "请选择项目类型", trigger: "change" },
    ],
  };
  const pageTitle = computed(() => (projectId.value ? "编辑项目" : "新增项目"));
  // åŽç«¯æŒ‰è®¡åˆ’起止日期派生 estimatedDays,这里只做展示,不参与提交
  const estimatedDays = computed(() => {
    const start = parseDate(form.value.planStartDate);
    const end = parseDate(form.value.planEndDate);
    if (!start || !end || end < start) return null;
    return Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
  });
  const goBack = () => uni.navigateBack();
  const addProduct = () => productList.value.push(createEmptyProduct());
  const removeProduct = index => {
    productList.value.splice(index, 1);
  };
  const addTeamRow = () =>
    teamList.value.push({
      memberId: "",
      memberName: "",
      roleId: "",
      roleName: "",
      enterDate: "",
      leaveDate: "",
      phone: "",
      remark: "",
    });
  const removeTeamRow = index => {
    teamList.value.splice(index, 1);
  };
  const addAddressRow = () =>
    addressList.value.push({ receiver: "", phone: "", address: "" });
  const loadProjectTypes = () => {
    listPlan({ current: 1, size: 999 })
      .then(res => {
        projectTypeOptions.value = unwrapList(res).map(item => ({
          name: item.name || item.planName || "",
          value: item.id,
        }));
      })
      .catch(() => {
        projectTypeOptions.value = [];
      });
  };
  const loadUsers = () => {
    userListNoPageByTenantId()
      .then(res => {
        const rows = unwrapList(res);
        userOptions.value = (Array.isArray(rows) ? rows : []).map(item => ({
          name: item.nickName || item.userName || "",
          value: item.userId ?? item.id,
        }));
      })
      .catch(() => {
        userOptions.value = [];
      });
  };
  const loadRoles = () => {
    findRoleListPage({ pageNum: 1, pageSize: 999 })
      .then(res => {
        roleOptions.value = unwrapList(res).map(item => ({
          name: item.roleName || item.name || "",
          value: item.id,
        }));
      })
      .catch(() => {
        roleOptions.value = [];
      });
  };
  const loadDetail = async () => {
    try {
      const res = await getProject(projectId.value);
      const detail = res?.data?.data ?? res?.data ?? res;
      const mapped = toForm(detail || {});
      form.value = { ...form.value, ...mapped.form };
      teamList.value = mapped.teamList;
      addressList.value = mapped.addressList;
      productList.value = mapped.productList;
      attachmentFiles.value = mapped.attachments;
    } catch (error) {
      uni.showToast({ title: "获取项目详情失败", icon: "none" });
    }
  };
  const validateProducts = () => {
    if (productList.value.length === 0) {
      uni.showToast({ title: "请添加产品信息", icon: "none" });
      return false;
    }
    const invalid = productList.value.some(row => !row.productCategoryId);
    if (invalid) {
      uni.showToast({ title: "请为每条产品选择产品大类", icon: "none" });
      return false;
    }
    return true;
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    if (!validateProducts()) return;
    // CommonUpload å­˜çš„æ˜¯ä¸Šä¼ æŽ¥å£è¿”回的对象,提交只要 id
    const attachmentIds = attachmentFiles.value
      .map(item => item?.id ?? item?.attachmentId)
      .filter(Boolean);
    loading.value = true;
    saveProject(
      toPayload({
        form: form.value,
        teamList: teamList.value,
        addressList: addressList.value,
        productList: productList.value,
        attachmentIds,
      })
    )
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 300);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(async options => {
    projectId.value = options?.id || "";
    if (projectId.value) form.value.id = projectId.value;
    loadProjectTypes();
    loadUsers();
    loadRoles();
    if (projectId.value) {
      await loadDetail();
    } else {
      // æ–°å¢žæ—¶å…ˆé“ºä¸€æ¡ç©ºè¡Œï¼Œå‡å°‘一次点击;产品必须至少一条,后端会校验
      addProduct();
      addAddressRow();
    }
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .project-edit {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .section-tools {
    display: flex;
    justify-content: flex-end;
    padding: 12px 12px 0;
  }
  .row-list {
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .empty-text {
    padding: 16px 12px;
    color: #999;
    font-size: 14px;
  }
  .upload-block {
    padding: 12px;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/projectManagement/project/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,357 @@
<template>
  <view class="sales-account">
    <PageHeader title="项目管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.projectNameOrCode"
                    placeholder="请输入项目名称/编号"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="projectList.length > 0"
          class="ledger-list">
      <view v-for="item in projectList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="grid"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.billNo || "-" }}</text>
          </view>
          <up-tag :text="dictLabel(auditOptions, item.auditStatus)"
                  :type="dictTagType(auditOptions, item.auditStatus)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">项目名称</text>
            <text class="detail-value highlight">{{ item.projectName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">客户名称</text>
            <text class="detail-value">{{ item.customerName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">立项日期</text>
            <text class="detail-value">{{ item.setupDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">项目来源</text>
            <text class="detail-value">{{ item.projectSource || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">项目分类</text>
            <text class="detail-value">{{ item.projectClassification || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">计划状态</text>
            <text class="detail-value">{{ dictLabel(stageOptions, item.projectStage) || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">项目金额</text>
            <text class="detail-value">{{ formatAmount(item.projectAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">销售合同总数</text>
            <text class="detail-value">{{ item.salesContractCount ?? 0 }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goProgress(item)">进度汇报</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goArchive(item)">文档归档</up-button>
          <up-button v-if="canSubmit(item)"
                     class="action-btn"
                     size="small"
                     type="success"
                     @click="handleSubmitRow(item)">提交</up-button>
          <up-button v-if="canAudit(item)"
                     class="action-btn"
                     size="small"
                     type="warning"
                     @click="handleAuditRow(item)">审核</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无项目数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="项目筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import { useDict } from "@/utils/dict";
  import { unwrapList } from "@/utils/list";
  import {
    auditProject,
    delProject,
    listProject,
    submitProject,
  } from "@/api/projectManagement/project";
  import { toListRow } from "./_utils/mapper";
  import { dictLabel, dictTagType, toPickerOptions } from "./_utils/dict";
  const { bill_status, project_management, plan_status } = useDict(
    "bill_status",
    "project_management",
    "plan_status"
  );
  const billOptions = computed(() => toPickerOptions(bill_status.value));
  const auditOptions = computed(() => toPickerOptions(project_management.value));
  const stageOptions = computed(() => toPickerOptions(plan_status.value));
  const projectList = ref([]);
  const showFilter = ref(false);
  // åŽç«¯æ²¡æœ‰å•据状态(billStatus)的查询参数,管理端也是拉回列表后本地过滤
  const query = reactive({
    projectNameOrCode: "",
    customerName: "",
    salesperson: "",
    billStatus: "",
    projectStage: "",
    auditStatus: "",
  });
  const filterFields = computed(() => [
    {
      prop: "projectNameOrCode",
      label: "项目名称/编号",
      type: "input",
      placeholder: "请输入项目名称/编号",
    },
    {
      prop: "customerName",
      label: "客户名称",
      type: "input",
      placeholder: "请输入客户名称",
    },
    {
      prop: "salesperson",
      label: "业务人员",
      type: "input",
      placeholder: "请输入业务人员",
    },
    {
      prop: "billStatus",
      label: "单据状态",
      type: "select",
      options: billOptions.value,
    },
    {
      prop: "projectStage",
      label: "计划状态",
      type: "select",
      options: stageOptions.value,
    },
    {
      prop: "auditStatus",
      label: "审核状态",
      type: "select",
      options: auditOptions.value,
    },
  ]);
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("projectDetail");
    uni.navigateTo({ url: "/pages/projectManagement/project/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("projectDetail", item || {});
    uni.navigateTo({
      url: `/pages/projectManagement/project/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("projectDetail", item || {});
    uni.navigateTo({
      url: `/pages/projectManagement/project/detail?id=${item.id}`,
    });
  };
  const goProgress = item => {
    uni.setStorageSync("projectDetail", item || {});
    uni.navigateTo({
      url: `/pages/projectManagement/project/progress?id=${item.id}`,
    });
  };
  const goArchive = item => {
    uni.navigateTo({
      url: `/pages/projectManagement/project/archive?id=${item.id}&name=${encodeURIComponent(item.projectName || "")}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  // æäº¤ / å®¡æ ¸æ˜¯çº¿æ€§æµç¨‹ï¼šæ–°å»º(无状态) â†’ æäº¤(reviewStatus 0) â†’ å®¡æ ¸(reviewStatus 1)。
  // ç®¡ç†ç«¯å¦æœ‰åå®¡æ ¸ï¼Œæ‰‹æœºç«¯æŒ‰çº¦å®šä¸åšã€‚
  const canSubmit = item => String(item?.auditStatus ?? "") === "";
  const canAudit = item => String(item?.auditStatus) === "0";
  const getList = () => {
    listProject({
      noOrName: query.projectNameOrCode || undefined,
      clientName: query.customerName || undefined,
      salesmanName: query.salesperson || undefined,
      reviewStatus: query.auditStatus || undefined,
      stage: query.projectStage || undefined,
      current: -1,
      size: -1,
    })
      .then(res => {
        const rows = unwrapList(res).map(toListRow);
        projectList.value = query.billStatus
          ? rows.filter(
              item => String(item.billStatus) === String(query.billStatus)
            )
          : rows;
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const confirmStatusAction = (item, label, content, action) => {
    uni.showModal({
      title: `${label}确认`,
      content,
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        action({ id: item.id })
          .then(() => {
            uni.showToast({ title: `${label}成功`, icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: `${label}失败`, icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  const handleSubmitRow = item => {
    if (!item?.id) return;
    confirmStatusAction(item, "提交", "确认提交该项目吗?", submitProject);
  };
  const handleAuditRow = item => {
    if (!item?.id) return;
    confirmStatusAction(item, "审核", "确认审核通过该项目吗?", auditProject);
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: `确认删除项目「${item.projectName || ""}」吗?`,
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delProject([item.id])
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
  .action-buttons {
    flex-wrap: wrap;
  }
  .action-btn {
    flex: 0 0 calc(33.33% - 8px);
  }
</style>
src/pages/projectManagement/project/progress.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,326 @@
<!--
  è¿›åº¦æ±‡æŠ¥ï¼ˆâ†’ POST /projectManagement/info/saveStage)
  å­—段以管理端 projectDetail.vue çš„ handleProgressSubmitted ç»„装出的请求体为准。
  ç®¡ç†ç«¯ ProgressReportDialog é‡Œè¿˜æœ‰ã€Œä¸Šæ¬¡è¿›åº¦ / è´Ÿè´£äºº / éƒ¨é—¨ã€ä¸‰ä¸ªè¾“入框,但它们
  å¹¶ä¸åœ¨è¯·æ±‚体里(负责人取的是当前登录人,不是表单值),所以这里不做可编辑控件:
  ã€Œä¸Šæ¬¡è¿›åº¦ã€ç”±å·²æœ‰æ±‡æŠ¥è®°å½•推算后只读展示,「负责人」显示将被记录的当前登录人,
  é¿å…å‡ºçŽ°å¡«äº†ä¸ç”Ÿæ•ˆçš„å­—æ®µã€‚
-->
<template>
  <view class="project-progress">
    <PageHeader title="进度汇报"
                @back="goBack" />
    <view v-if="projectName"
          class="project-banner">
      <text class="banner-label">项目名称</text>
      <text class="banner-value">{{ projectName }}</text>
    </view>
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="阶段信息"
                      class="form-section">
          <up-form-item label="项目阶段"
                        prop="planNodeId"
                        required>
            <FormPicker v-model="form.planNodeId"
                        type="select"
                        title="选择项目阶段"
                        :options="stageOptions"
                        placeholder="请选择项目阶段" />
          </up-form-item>
          <up-form-item label="计划开始时间"
                        prop="planStartTime"
                        required>
            <FormPicker v-model="form.planStartTime"
                        type="date"
                        placeholder="请选择计划开始时间" />
          </up-form-item>
          <up-form-item label="计划完工时间"
                        prop="planEndTime"
                        required>
            <FormPicker v-model="form.planEndTime"
                        type="date"
                        placeholder="请选择计划完工时间" />
          </up-form-item>
          <up-form-item label="实际开工日期"
                        prop="actualStartTime">
            <FormPicker v-model="form.actualStartTime"
                        type="date"
                        placeholder="请选择实际开工日期" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="进度情况"
                      class="form-section">
          <up-form-item label="本次进度日期"
                        prop="reportDate"
                        required>
            <FormPicker v-model="form.reportDate"
                        type="date"
                        placeholder="请选择本次进度日期" />
          </up-form-item>
          <up-form-item label="上次进度(%)">
            <up-input :model-value="String(lastProgress)"
                      disabled />
          </up-form-item>
          <up-form-item label="完成进度(%)"
                        prop="completionProgress"
                        required>
            <view class="progress-input">
              <up-input v-model="form.completionProgress"
                        type="number"
                        placeholder="请输入完成进度" />
              <up-button type="primary"
                         size="small"
                         text="完成"
                         @click="markDone" />
            </view>
          </up-form-item>
          <up-form-item label="累计进度(%)"
                        prop="totalProgress">
            <up-input v-model="form.totalProgress"
                      type="number"
                      placeholder="留空则取完成进度" />
          </up-form-item>
          <up-form-item label="实际完工日期"
                        prop="actualEndTime">
            <FormPicker v-model="form.actualEndTime"
                        type="date"
                        placeholder="请选择实际完工日期" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="汇报信息"
                      class="form-section">
          <up-form-item label="负责人">
            <up-input :model-value="leaderName || '当前登录人'"
                      disabled />
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-textarea v-model="form.remark"
                         placeholder="请输入备注"
                         :maxlength="200"
                         count
                         auto-height />
          </up-form-item>
          <view class="upload-block">
            <CommonUpload v-model="attachmentFiles"
                          accept="all" />
          </view>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirm-text="提交汇报"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import useUserStore from "@/store/modules/user";
  import { getProject, saveStage } from "@/api/projectManagement/project";
  import {
    buildStagePayload,
    defaultPlanNodeId,
    fetchPlanNodeList,
    fetchProjectStages,
    planNodeOptions,
  } from "./_utils/stage";
  const userStore = useUserStore();
  const formRef = ref();
  const loading = ref(false);
  const projectId = ref("");
  const info = ref({});
  const projectName = ref("");
  const planNodes = ref([]);
  const lastProgress = ref(0);
  const attachmentFiles = ref([]);
  const leaderName = computed(() => userStore.nickName || "");
  const stageOptions = computed(() => planNodeOptions(planNodes.value));
  const form = ref({
    planNodeId: "",
    planStartTime: "",
    planEndTime: "",
    actualStartTime: "",
    actualEndTime: "",
    reportDate: "",
    completionProgress: "",
    totalProgress: "",
    remark: "",
  });
  const rules = {
    planNodeId: [{ required: true, message: "请选择项目阶段", trigger: "change" }],
    planStartTime: [
      { required: true, message: "请选择计划开始时间", trigger: "change" },
    ],
    planEndTime: [
      { required: true, message: "请选择计划完工时间", trigger: "change" },
    ],
    reportDate: [
      { required: true, message: "请选择本次进度日期", trigger: "change" },
    ],
    completionProgress: [
      { required: true, message: "请输入完成进度", trigger: "blur" },
    ],
  };
  const goBack = () => uni.navigateBack();
  // åŽç«¯ä¸æŽ¥æ”¶ã€Œä¸Šæ¬¡è¿›åº¦ã€ï¼Œç”±å·²æ±‡æŠ¥è®°å½•里的最大 progress æŽ¨ç®—,只作填写参考
  const deriveLastProgress = stages =>
    (Array.isArray(stages) ? stages : []).reduce((max, row) => {
      const value = Number(row?.progress ?? 0) || 0;
      return value > max ? value : max;
    }, 0);
  const markDone = () => {
    form.value.completionProgress = "100";
    form.value.totalProgress = "100";
    if (!form.value.actualEndTime) form.value.actualEndTime = form.value.reportDate;
  };
  const loadDetail = async () => {
    try {
      const res = await getProject(projectId.value);
      const raw = res?.data?.data ?? res?.data ?? res;
      info.value = raw?.info || {};
      projectName.value = info.value.title || "";
      const [nodes, stages] = await Promise.all([
        fetchPlanNodeList(info.value.projectManagementPlanId),
        fetchProjectStages(projectId.value),
      ]);
      planNodes.value = nodes;
      lastProgress.value = deriveLastProgress(stages);
      form.value.planNodeId = defaultPlanNodeId(info.value.stage, nodes);
      form.value.planStartTime = info.value.planStartTime || "";
      form.value.planEndTime = info.value.planEndTime || "";
      form.value.actualStartTime = info.value.actualStartTime || "";
      form.value.actualEndTime = info.value.actualEndTime || "";
      // æœ‰åŽ†å²æ±‡æŠ¥æ‰é¢„å¡«ç´¯è®¡è¿›åº¦ï¼›æ²¡æœ‰åˆ™ç•™ç©ºï¼Œæäº¤æ—¶é€€å›žã€Œå®Œæˆè¿›åº¦ã€
      form.value.totalProgress = lastProgress.value
        ? String(lastProgress.value)
        : "";
    } catch (error) {
      uni.showToast({ title: "获取项目详情失败", icon: "none" });
    }
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    loading.value = true;
    saveStage(
      buildStagePayload({
        projectId: projectId.value,
        projectInfo: info.value,
        planNodes: planNodes.value,
        form: form.value,
        attachments: attachmentFiles.value,
        leaderId: userStore.id,
        leaderName: userStore.nickName,
      })
    )
      .then(() => {
        uni.showToast({ title: "汇报成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 300);
      })
      .catch(() => {
        uni.showToast({ title: "汇报失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    projectId.value = options?.id || "";
    if (projectId.value) loadDetail();
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .project-progress {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .project-banner {
    margin: 12px 12px 0;
    padding: 14px 16px;
    border-radius: 12px;
    background: #ffffff;
    display: flex;
    flex-direction: column;
    gap: 6px;
  }
  .banner-label {
    font-size: 13px;
    color: #909399;
  }
  .banner-value {
    font-size: 16px;
    font-weight: 600;
    color: #22324d;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .progress-input {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
  }
  .upload-block {
    padding: 12px;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/qualityManagement/finalInspection/_utils/inspection.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
/**
 * æ£€éªŒé¡¹åˆ¤å®šï¼ˆéœ€æ±‚九.3)
 *
 * åˆ¤å®šå£å¾„逐字复刻管理端 qualityManagement/finalInspection/components/formDia.vue
 * é‡Œ watch(tableData) çš„那段逻辑,不要自行发明:
 *   æ•°å­—åž‹(parameterType === 1):minValue <= æ£€éªŒå€¼ <= maxValue
 *   æ–‡å­—åž‹(parameterType å…¶ä½™):检验值全等 standardValue
 *   å…¨éƒ¨é¡¹é€šè¿‡ â†’ åˆæ ¼ï¼›å…¨éƒ¨é¡¹æœªé€šè¿‡ â†’ ä¸åˆæ ¼ï¼›å…¶ä½™ â†’ éƒ¨åˆ†åˆæ ¼
 *
 * æ³¨æ„åˆ¤å®šç”¨çš„æ˜¯æœ€å¤§å€¼/最小值,"内控值(controlValue)"只作展示、不参与判定。
 * ç®¡ç†ç«¯åœ¨æ£€éªŒé¡¹ä¸ºç©ºæ—¶è·³è¿‡åˆ¤å®šï¼Œè¿™é‡Œå¯¹åº”返回空串,由调用方决定是否保留原值。
 */
export const TEXT_TYPE = 0;
export const NUMERIC_TYPE = 1;
/** å•行是否判定通过 */
export function isItemPass(item) {
  const val = item?.testValue;
  if (val === null || val === undefined || val === "") return false;
  if (item.parameterType === NUMERIC_TYPE) {
    const numVal = Number(val);
    const min = Number(item.minValue);
    const max = Number(item.maxValue);
    return !isNaN(numVal) && numVal >= min && numVal <= max;
  }
  return String(val) === String(item.standardValue);
}
/** æ£€éªŒé¡¹åˆ—表 â†’ æ£€æµ‹ç»“果(合格 / ä¸åˆæ ¼ / éƒ¨åˆ†åˆæ ¼ï¼‰ï¼Œç©ºåˆ—表返回空串 */
export function judgeInspectionResult(rows) {
  const list = Array.isArray(rows) ? rows : [];
  if (list.length === 0) return "";
  let passCount = 0;
  let failCount = 0;
  list.forEach(item => {
    if (isItemPass(item)) passCount++;
    else failCount++;
  });
  if (failCount === 0 && passCount > 0) return "合格";
  if (passCount === 0 && failCount > 0) return "不合格";
  return "部分合格";
}
/** ä¸´æ—¶è¿½åŠ çš„æ£€æµ‹é¡¹è¡Œï¼ˆéœ€æ±‚ä¹.2):不受指标标准约束,各字段都可编辑 */
export function createTempItem() {
  return {
    id: undefined,
    isTemp: true,
    parameterItem: "",
    parameterType: NUMERIC_TYPE,
    unit: "",
    standardValue: "",
    controlValue: "",
    maxValue: null,
    minValue: null,
    testValue: "",
  };
}
src/pages/qualityManagement/finalInspection/add.vue
@@ -125,7 +125,13 @@
      <view class="inspection-items-container">
        <view class="steps-header">
          <text class="steps-title">检验项目</text>
          <view class="steps-header-right">
          <text class="steps-count">共 {{ tableData.length }} ä¸ªé¡¹ç›®</text>
            <up-button type="primary"
                       size="mini"
                       text="追加检测项"
                       @click="addTempItem" />
          </view>
        </view>
        <view class="steps-list">
          <view v-for="(item, index) in tableData"
@@ -137,28 +143,106 @@
            <view class="step-content">
              <view class="step-row">
                <text class="step-label">指标:</text>
                <text class="step-value">{{ item.parameterItem }}</text>
                <up-input v-if="item.isTemp"
                          v-model="item.parameterItem"
                          placeholder="请输入指标名称"
                          border-bottom
                          class="step-input" />
                <text v-else
                      class="step-value">{{ item.parameterItem }}</text>
              </view>
              <view class="step-row">
                <text class="step-label">参数类型:</text>
                <view v-if="item.isTemp"
                      class="type-switch">
                  <text class="type-option"
                        :class="{ active: item.parameterType === TEXT_TYPE }"
                        @click="setTempType(item, TEXT_TYPE)">文字</text>
                  <text class="type-option"
                        :class="{ active: item.parameterType === NUMERIC_TYPE }"
                        @click="setTempType(item, NUMERIC_TYPE)">数字</text>
                </view>
                <text v-else
                      class="step-value">{{ isNumeric(item) ? "数字" : "文字" }}</text>
              </view>
              <!-- æ•°å­—型按上下限判定,把上下限摆出来才看得懂自动判定的依据 -->
              <template v-if="isNumeric(item)">
                <view class="step-row">
                  <text class="step-label">最大值:</text>
                  <up-input v-if="item.isTemp"
                            v-model="item.maxValue"
                            type="number"
                            placeholder="请输入最大值"
                            border-bottom
                            class="step-input" />
                  <text v-else
                        class="step-value">{{ item.maxValue }}</text>
                </view>
                <view class="step-row">
                  <text class="step-label">最小值:</text>
                  <up-input v-if="item.isTemp"
                            v-model="item.minValue"
                            type="number"
                            placeholder="请输入最小值"
                            border-bottom
                            class="step-input" />
                  <text v-else
                        class="step-value">{{ item.minValue }}</text>
                </view>
              </template>
              <view class="step-row">
                <text class="step-label">单位:</text>
                <text class="step-value">{{ item.unit }}</text>
                <up-input v-if="item.isTemp"
                          v-model="item.unit"
                          placeholder="请输入单位"
                          border-bottom
                          class="step-input" />
                <text v-else
                      class="step-value">{{ item.unit }}</text>
              </view>
              <view class="step-row">
                <text class="step-label">标准值:</text>
                <text class="step-value">{{ item.standardValue }}</text>
                <up-input v-if="item.isTemp"
                          v-model="item.standardValue"
                          placeholder="请输入标准值"
                          border-bottom
                          class="step-input" />
                <text v-else
                      class="step-value">{{ item.standardValue }}</text>
              </view>
              <view class="step-row">
                <text class="step-label">内控值:</text>
                <text class="step-value">{{ item.controlValue }}</text>
                <up-input v-if="item.isTemp"
                          v-model="item.controlValue"
                          placeholder="请输入内控值"
                          border-bottom
                          class="step-input" />
                <text v-else
                      class="step-value">{{ item.controlValue }}</text>
              </view>
              <view class="step-row">
                <text class="step-label">检验值:</text>
                <up-input v-model="item.testValue"
                          :type="isNumeric(item) ? 'number' : 'text'"
                          placeholder="请输入检验值"
                          clearable
                          border-bottom
                          class="step-input" />
              </view>
              <view class="step-row">
                <text class="step-label">判定:</text>
                <text class="step-value judge-text"
                      :class="itemPassClass(item)">
                  {{ judgeText(item) }}
                </text>
              </view>
            </view>
            <view v-if="item.isTemp"
                  class="delete-btn"
                  @click="removeTempItem(index)">
              <up-icon name="close"
                       size="14"
                       color="#ffffff" />
            </view>
          </view>
          <view v-if="tableData.length === 0"
@@ -302,7 +386,7 @@
</template>
<script setup>
  import { ref, computed, onMounted, nextTick } from "vue";
  import { ref, computed, onMounted, nextTick, watch } from "vue";
  import { onShow, onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import dayjs from "dayjs";
@@ -317,6 +401,13 @@
    list,
  } from "@/api/qualityManagement/materialInspection.js";
  import { userListNoPage } from "@/api/system/user.js";
  import {
    NUMERIC_TYPE,
    TEXT_TYPE,
    createTempItem,
    isItemPass,
    judgeInspectionResult,
  } from "./_utils/inspection";
  // æ˜¾ç¤ºæç¤ºä¿¡æ¯
  const showToast = message => {
@@ -671,6 +762,54 @@
    });
  };
  // æ•°å­—型才有最大/最小值,判定也按上下限走
  const isNumeric = item => item?.parameterType === NUMERIC_TYPE;
  const judgeText = item => {
    const val = item?.testValue;
    if (val === null || val === undefined || val === "") return "-";
    return isItemPass(item) ? "合格" : "不合格";
  };
  const itemPassClass = item => {
    const val = item?.testValue;
    if (val === null || val === undefined || val === "") return "";
    return isItemPass(item) ? "pass" : "fail";
  };
  // éœ€æ±‚九.3:检验值一变就自动判定,口径见 _utils/inspection.js。
  // ä¸Žç®¡ç†ç«¯ä¸€è‡´ï¼Œè¿™é‡Œæ— æ¡ä»¶è¦†ç›– form.checkResult â€”— æ‰‹å·¥é€‰çš„æ£€æµ‹ç»“æžœ
  // åªæ˜¯ä¸´æ—¶å…œåº•,下次改动检验项时会被重新判定覆盖。
  watch(
    tableData,
    rows => {
      const result = judgeInspectionResult(rows);
      if (result) form.value.checkResult = result;
    },
    { deep: true }
  );
  // éœ€æ±‚九.2:临时追加检测项
  const addTempItem = () => {
    tableData.value.push(createTempItem());
  };
  const removeTempItem = index => {
    tableData.value.splice(index, 1);
  };
  // åˆ‡ç±»åž‹æ—¶æ¸…掉不再适用的值,避免文字型残留上下限、数字型残留标准值
  const setTempType = (item, type) => {
    if (item.parameterType === type) return;
    item.parameterType = type;
    if (type === NUMERIC_TYPE) {
      item.standardValue = "";
    } else {
      item.maxValue = null;
      item.minValue = null;
    }
  };
  // æäº¤è¡¨å•
  const submitForm = async () => {
    console.log("submitForm", form.value, tableData.value);
@@ -700,10 +839,19 @@
        showToast("请选择检测结果");
        return;
      }
      const missingTemp = tableData.value.find(
        item => item.isTemp && !item.parameterItem
      );
      if (missingTemp) {
        showToast("请填写追加检测项的指标名称");
        return;
      }
      loading.value = true;
      form.value.inspectType = 2;
      // isTemp åªæ˜¯å‰ç«¯çš„行标记,不能提交给后端
      tableData.value = tableData.value.map(({ isTemp, ...rest }) => rest);
      if (!isEdit.value) {
        tableData.value.forEach(item => {
          delete item.id;
@@ -931,6 +1079,40 @@
    color: #909399;
  }
  .steps-header-right {
    display: flex;
    align-items: center;
    gap: 10px;
  }
  // ä¸´æ—¶è¿½åŠ è¡Œçš„å‚æ•°ç±»åž‹åˆ‡æ¢
  .type-switch {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .type-option {
    padding: 4px 14px;
    font-size: 13px;
    color: #606266;
    background: #f4f4f5;
    border-radius: 12px;
  }
  .type-option.active {
    color: #409eff;
    background: #ecf5ff;
  }
  .judge-text.pass {
    color: #67c23a;
  }
  .judge-text.fail {
    color: #f56c6c;
  }
  .steps-list {
    margin-bottom: 20px;
  }
src/pages/qualityManagement/finalInspection/detail.vue
@@ -94,6 +94,12 @@
                <text class="item-detail-label">单位:</text>
                <text class="item-detail-value">{{ item.unit || '-' }}</text>
              </view>
              <!-- æ•°å­—型按上下限判定,把上下限摆出来才看得懂判定依据 -->
              <view v-if="isNumeric(item)"
                    class="item-detail-row">
                <text class="item-detail-label">上下限:</text>
                <text class="item-detail-value">{{ item.minValue }} ~ {{ item.maxValue }}</text>
              </view>
              <view class="item-detail-row">
                <text class="item-detail-label">标准值:</text>
                <text class="item-detail-value">{{ item.standardValue || '-' }}</text>
@@ -104,23 +110,28 @@
              </view>
              <view class="item-detail-row">
                <text class="item-detail-label">检验值:</text>
                <text class="item-detail-value">{{ item.testValue || '-' }}</text>
              </view>
              <view class="item-detail-row">
                <text class="item-detail-label">判定:</text>
                <text class="item-detail-value result"
                      :class="getResultClass(item.testValue, item.standardValue)">
                  {{ item.testValue || '-' }}
                      :class="judgeClass(item)">
                  {{ judgeText(item) }}
                </text>
              </view>
            </view>
          </view>
        </view>
      </view>
      <!-- æ“ä½œæŒ‰é’® -->
      <!-- <view class="action-buttons">
      <!-- éœ€æ±‚九.4:下载 PDF åˆæ ¼è¯ -->
      <view class="action-buttons">
        <u-button type="primary"
                  class="action-btn"
                  :loading="downloading"
                  @click="downloadReport">
          ä¸‹è½½æŠ¥å‘Š
          ä¸‹è½½åˆæ ¼è¯PDF
        </u-button>
      </view> -->
      </view>
    </view>
    <view v-else
          class="no-data">
@@ -136,6 +147,8 @@
  import PageHeader from "@/components/PageHeader.vue";
  import dayjs from "dayjs";
  import { qualityInspectParamInfo } from "@/api/qualityManagement/materialInspection.js";
  import { downloadQualityInspectPdf } from "@/api/qualityManagement/qualityInspectFile.js";
  import { NUMERIC_TYPE, isItemPass } from "./_utils/inspection";
  // æ˜¾ç¤ºæç¤ºä¿¡æ¯
  const showToast = message => {
@@ -149,6 +162,8 @@
  const detailData = ref(null);
  // æ£€éªŒé¡¹ç›®
  const inspectionItems = ref([]);
  // åˆæ ¼è¯PDF ä¸‹è½½ä¸­
  const downloading = ref(false);
  // è¿”回上一页
  const goBack = () => {
@@ -178,31 +193,34 @@
    return state ? "success" : "warning";
  };
  // èŽ·å–ç»“æžœæ ·å¼
  const getResultClass = (testValue, standardValue) => {
    // ç®€å•的结果判断逻辑,实际项目中可能需要更复杂的判断
    if (testValue === "合格") {
      return "result-passed";
    } else if (testValue === "不合格") {
      return "result-rejected";
    }
    return "";
  // æ•°å­—型才有上下限
  const isNumeric = item => item?.parameterType === NUMERIC_TYPE;
  const judgeText = item => {
    const val = item?.testValue;
    if (val === null || val === undefined || val === "") return "-";
    return isItemPass(item) ? "合格" : "不合格";
  };
  // ä¸‹è½½æŠ¥å‘Š
  const downloadReport = () => {
    uni.showToast({
      title: "报告下载中...",
      icon: "loading",
    });
  const judgeClass = item => {
    const val = item?.testValue;
    if (val === null || val === undefined || val === "") return "";
    return isItemPass(item) ? "result-passed" : "result-rejected";
  };
    // æ¨¡æ‹Ÿä¸‹è½½
    setTimeout(() => {
      uni.showToast({
        title: "报告下载成功",
        icon: "success",
  // ä¸‹è½½ PDF åˆæ ¼è¯
  const downloadReport = () => {
    if (!optionsId.value) {
      showToast("参数错误");
      return;
    }
    downloading.value = true;
    // downloadFileByPost å†…部已 toast,这里只负责收尾 loading
    downloadQualityInspectPdf(optionsId.value)
      .catch(() => {})
      .finally(() => {
        downloading.value = false;
      });
    }, 1500);
  };
  // èŽ·å–è¯¦æƒ…æ•°æ®
@@ -413,6 +431,14 @@
    color: #f56c6c;
  }
  .action-buttons {
    padding: 4px 0 8px;
  }
  .action-btn {
    width: 100%;
  }
  // ç©ºçŠ¶æ€
  .no-data {
    padding: 60px 20px;
src/pages/qualityManagement/finalInspection/fileList.vue
@@ -3,6 +3,15 @@
    <!-- é¡µé¢å¤´éƒ¨ -->
    <PageHeader title="附件管理"
                @back="goBack" />
    <!-- éœ€æ±‚九.4:下载 PDF åˆæ ¼è¯ -->
    <view class="pdf-bar">
      <u-button type="primary"
                size="small"
                :loading="downloadingPdf"
                @click="downloadPdf">
        ä¸‹è½½åˆæ ¼è¯PDF
      </u-button>
    </view>
    <!-- é™„件列表 -->
    <view class="file-list-container">
      <view v-if="fileList.length > 0"
@@ -78,10 +87,28 @@
    qualityInspectFileListPage,
    qualityInspectFileDel,
  } from "@/api/qualityManagement/materialInspection";
  import { downloadQualityInspectPdf } from "@/api/qualityManagement/qualityInspectFile.js";
  import { openFile } from "@/utils/file";
  import { blobValidate } from "@/utils/ruoyi";
  // é™„件列表
  const fileList = ref([]);
  // åˆæ ¼è¯PDF ä¸‹è½½ä¸­
  const downloadingPdf = ref(false);
  // ä¸‹è½½ PDF åˆæ ¼è¯ï¼ˆä¸Žé™„件同一份 inspectId)
  const downloadPdf = () => {
    if (!rulesRegulationsManagementId.value) {
      showToast("参数错误");
      return;
    }
    downloadingPdf.value = true;
    downloadQualityInspectPdf(rulesRegulationsManagementId.value)
      .catch(() => {})
      .finally(() => {
        downloadingPdf.value = false;
      });
  };
  // è¿”回上一页
  const goBack = () => {
@@ -259,81 +286,11 @@
      });
    });
  };
  // ä¸‹è½½æ–‡ä»¶
  // ä¸‹è½½å¹¶é¢„览。
  // åŽŸæ¥èµ°çš„æ˜¯ /common/download?...&delete=true â€”— ä¼šæŠŠæœåŠ¡ç«¯é™„ä»¶åˆ æŽ‰ï¼Œ
  // ä¸”回调里引用了未定义的 resolve/reject。改为直接按 url æ‰“开。
  const downloadFile = file => {
    var url =
      config.baseUrl +
      "/common/download?fileName=" +
      encodeURIComponent(file.url) +
      "&delete=true";
    console.log(url, "url");
    uni
      .downloadFile({
        url: url,
        responseType: "blob",
        header: { Authorization: "Bearer " + getToken() },
      })
      .then(res => {
        let osType = uni.getStorageSync("deviceInfo").osName;
        let filePath = res.tempFilePath;
        if (osType === "ios") {
          uni.openDocument({
            filePath: filePath,
            showMenu: true,
            success: res => {
              resolve(res);
            },
            fail: err => {
              console.log("uni.openDocument--fail");
              reject(err);
            },
          });
        } else {
          uni.saveFile({
            tempFilePath: filePath,
            success: fileRes => {
              uni.showToast({
                icon: "none",
                mask: true,
                title:
                  "文件已保存:Android/data/uni.UNI720216F/apps/__UNI__720216F/" +
                  fileRes.savedFilePath, //保存路径
                duration: 3000,
              });
              setTimeout(() => {
                //打开文档查看
                uni.openDocument({
                  filePath: fileRes.savedFilePath,
                  success: function (res) {
                    resolve(fileRes);
                  },
                });
              }, 3000);
            },
            fail: err => {
              console.log("uni.save--fail");
              reject(err);
            },
          });
        }
        // const isBlob = blobValidate(res.data);
        // if (isBlob) {
        //   const blob = new Blob([res.data], { type: "text/plain" });
        //   const url = URL.createObjectURL(blob);
        //   const downloadLink = document.getElementById("downloadLink");
        //   downloadLink.href = url;
        //   downloadLink.download = file.name;
        //   downloadLink.click();
        //   showToast("下载成功");
        // } else {
        //   showToast("下载失败");
        // }
      })
      .catch(err => {
        console.error("下载失败:", err);
        showToast("下载失败");
      });
    openFile(file?.url);
  };
  // ç¡®è®¤åˆ é™¤
@@ -431,6 +388,10 @@
    padding: 20rpx;
  }
  .pdf-bar {
    padding: 16rpx 20rpx 0;
  }
  .file-list {
    background: #ffffff;
    border-radius: 8rpx;
src/pages/qualityManagement/inspectionReport/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,264 @@
<template>
  <view class="inspection-report-detail">
    <PageHeader title="报告详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view class="detail-card"
          v-if="!loading">
      <view class="hero">
        <text class="hero-title">{{ detail.reportNo || "-" }}</text>
        <up-tag :text="detail.reportType || '未分类'"
                type="primary"
                size="mini"
                shape="circle" />
      </view>
      <view class="warn-banner"
            v-if="warn.text"
            :style="{ background: warn.level === 'normal' ? '#f0f9eb' : '#fef0f0' }">
        <text class="warn-banner-text"
              :style="{ color: warn.color }">有效期状态:{{ warn.text }}(到期时间 {{ detail.expirationTime || "-" }})</text>
      </view>
      <u-cell-group title="报告信息"
                    class="detail-section">
        <u-cell title="产品名称"
                :value="detail.productName || '-'" />
        <u-cell title="产品型号"
                :value="detail.productModel || '-'" />
        <u-cell title="报告编号"
                :value="detail.reportNo || '-'" />
        <u-cell title="报告类型"
                :value="detail.reportType || '-'" />
        <u-cell title="检测单位"
                :value="detail.testingAgency || '-'" />
      </u-cell-group>
      <u-cell-group title="有效期"
                    class="detail-section">
        <u-cell title="报告日期"
                :value="detail.reportDate || '-'" />
        <u-cell title="有效期(月)"
                :value="detail.validityPeriod != null ? String(detail.validityPeriod) : '-'" />
        <u-cell title="到期时间"
                :value="detail.expirationTime || '-'" />
      </u-cell-group>
      <u-cell-group title="备注"
                    class="detail-section">
        <u-cell title="备注"
                :value="detail.remark || '-'" />
      </u-cell-group>
      <u-cell-group title="附件"
                    class="detail-section">
        <view v-if="attachments.length === 0"
              class="empty-file">
          <text>暂无附件</text>
        </view>
        <view v-else
              class="file-list">
          <view v-for="(file, index) in attachments"
                :key="index"
                class="file-item"
                @click="previewFile(file, index)">
            <up-icon name="file-text"
                     size="18"
                     color="#2979ff"></up-icon>
            <text class="file-name">{{ fileLabel(file, index) }}</text>
            <up-icon name="download"
                     size="16"
                     color="#909399"></up-icon>
          </view>
        </view>
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { toFileList } from "@/utils/file";
  import { loadDetailData } from "@/utils/detailCache";
  import { getWarnInfo } from "@/utils/warning";
  import { getInspectionReport } from "@/api/qualityManagement/inspectionReport";
  const WARN_DAYS = 30;
  const loading = ref(true);
  const reportId = ref("");
  const detail = ref({});
  const attachments = computed(() => toFileList(detail.value.attachmentUrls));
  const warn = computed(() => getWarnInfo(detail.value.expirationTime, WARN_DAYS));
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("inspectionReportDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/qualityManagement/inspectionReport/edit?id=${reportId.value}`,
    });
  };
  const fileLabel = (file, index) =>
    file.name || file.originalFilename || `附件${index + 1}`;
  // request.ts æœªé€ä¼  responseType,blob ä¸‹è½½ä¸å¯ç”¨ï¼Œåªèƒ½ç”¨ downloadFile + openDocument
  const previewFile = (file, index) => {
    const url = file.url || file.previewURL;
    if (!url) return;
    uni.showLoading({ title: "打开中...", mask: true });
    uni.downloadFile({
      url,
      success: res => {
        if (res.statusCode !== 200) {
          uni.showToast({ title: "文件下载失败", icon: "none" });
          return;
        }
        uni.openDocument({
          filePath: res.tempFilePath,
          showMenu: true,
          fail: () => uni.showToast({ title: "暂不支持预览该文件", icon: "none" }),
        });
      },
      fail: () => uni.showToast({ title: "文件下载失败", icon: "none" }),
      complete: () => uni.hideLoading(),
    });
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      const data = await loadDetailData({
        cacheKey: "inspectionReportDetail",
        id: reportId.value,
        fetcher: getInspectionReport,
      });
      detail.value = data || {};
    } catch (error) {
      uni.showToast({ title: "获取报告详情失败", icon: "none" });
      detail.value = {};
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    reportId.value = options?.id || "";
    if (!reportId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .inspection-report-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .warn-banner {
    margin-top: 12px;
    padding: 10px 14px;
    border-radius: 10px;
  }
  .warn-banner-text {
    font-size: 13px;
    line-height: 1.5;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .empty-file {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .file-list {
    padding: 6px 16px 12px;
  }
  .file-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 0;
    border-bottom: 1px solid #f2f4f8;
  }
  .file-item:last-child {
    border-bottom: none;
  }
  .file-name {
    flex: 1;
    font-size: 14px;
    color: #333;
    word-break: break-all;
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/qualityManagement/inspectionReport/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,290 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="110"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="报告信息"
                      class="form-section">
          <up-form-item label="产品名称"
                        prop="productName"
                        required
                        border-bottom>
            <up-input v-model="form.productName"
                      placeholder="请输入产品名称"
                      clearable />
          </up-form-item>
          <up-form-item label="产品型号"
                        prop="productModel"
                        border-bottom>
            <up-input v-model="form.productModel"
                      placeholder="请输入产品型号"
                      clearable />
          </up-form-item>
          <up-form-item label="报告编号"
                        prop="reportNo"
                        required
                        border-bottom>
            <up-input v-model="form.reportNo"
                      placeholder="请输入报告编号"
                      clearable />
          </up-form-item>
          <up-form-item label="报告类型"
                        prop="reportType"
                        border-bottom>
            <FormPicker v-model="form.reportType"
                        type="select"
                        title="选择报告类型"
                        :options="reportTypeOptions"
                        placeholder="请选择报告类型" />
          </up-form-item>
          <up-form-item label="检测单位"
                        prop="testingAgency"
                        border-bottom>
            <up-input v-model="form.testingAgency"
                      placeholder="请输入检测单位"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="有效期"
                      class="form-section">
          <up-form-item label="报告日期"
                        prop="reportDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.reportDate"
                        type="date"
                        placeholder="请选择报告日期"
                        @change="calculateExpiration" />
          </up-form-item>
          <up-form-item label="有效期(月)"
                        prop="validityPeriod"
                        required
                        border-bottom>
            <up-input v-model="form.validityPeriod"
                      type="number"
                      placeholder="1~120,默认 12"
                      clearable
                      @blur="calculateExpiration" />
          </up-form-item>
          <up-form-item label="到期时间"
                        prop="expirationTime"
                        required
                        border-bottom>
            <up-input v-model="form.expirationTime"
                      placeholder="系统自动计算"
                      disabled />
          </up-form-item>
          <up-form-item label="备注"
                        prop="remark">
            <up-textarea v-model="form.remark"
                         placeholder="请输入备注内容"
                         auto-height
                         maxlength="500" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="附件"
                      class="form-section">
          <view class="upload-wrap">
            <CommonUpload v-model="form.attachmentFiles"
                          accept="all"
                          :max-count="5" />
          </view>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { toFileList, toUrlString } from "@/utils/file";
  import { loadDetailData } from "@/utils/detailCache";
  import { addMonths } from "@/utils/warning";
  import {
    addInspectionReport,
    getInspectionReport,
    updateInspectionReport,
  } from "@/api/qualityManagement/inspectionReport";
  const formRef = ref();
  const loading = ref(false);
  const reportId = ref("");
  const reportTypeOptions = [
    { name: "型式试验报告", value: "型式试验报告" },
    { name: "出厂检验报告", value: "出厂检验报告" },
    { name: "委托检验报告", value: "委托检验报告" },
  ];
  const form = ref({
    id: undefined,
    productName: "",
    productModel: "",
    reportNo: "",
    reportType: "",
    testingAgency: "",
    reportDate: "",
    validityPeriod: 12,
    expirationTime: "",
    remark: "",
    attachmentFiles: [],
  });
  const rules = {
    productName: [
      { required: true, message: "请输入产品名称", trigger: "blur" },
    ],
    reportNo: [{ required: true, message: "请输入报告编号", trigger: "blur" }],
    reportDate: [
      { required: true, message: "请选择报告日期", trigger: "change" },
    ],
    validityPeriod: [
      { required: true, message: "请输入有效期", trigger: "blur" },
    ],
    expirationTime: [
      { required: true, message: "到期时间不能为空", trigger: "change" },
    ],
  };
  const pageTitle = computed(() => (reportId.value ? "编辑报告" : "新增报告"));
  // åˆ°æœŸæ—¶é—´ = æŠ¥å‘Šæ—¥æœŸ + æœ‰æ•ˆæœŸæœˆï¼Œåªè¯»
  const calculateExpiration = () => {
    const months = Number(form.value.validityPeriod);
    if (form.value.reportDate && Number.isFinite(months) && months > 0) {
      form.value.expirationTime = addMonths(form.value.reportDate, months);
    } else {
      form.value.expirationTime = "";
    }
  };
  const goBack = () => uni.navigateBack();
  const loadDetail = async () => {
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "inspectionReportDetail",
        id: reportId.value,
        fetcher: getInspectionReport,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "未找到报告信息", icon: "none" });
      return;
    }
    form.value = {
      id: data.id,
      productName: data.productName || "",
      productModel: data.productModel || "",
      reportNo: data.reportNo || "",
      reportType: data.reportType || "",
      testingAgency: data.testingAgency || "",
      reportDate: (data.reportDate || "").slice(0, 10),
      validityPeriod: data.validityPeriod ?? 12,
      expirationTime: (data.expirationTime || "").slice(0, 10),
      remark: data.remark || "",
      attachmentFiles: toFileList(data.attachmentUrls),
    };
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    calculateExpiration();
    const payload = {
      id: form.value.id,
      productName: form.value.productName,
      productModel: form.value.productModel,
      reportNo: form.value.reportNo,
      reportType: form.value.reportType,
      testingAgency: form.value.testingAgency,
      reportDate: form.value.reportDate,
      validityPeriod: Number(form.value.validityPeriod),
      expirationTime: form.value.expirationTime,
      remark: form.value.remark,
      attachmentUrls: toUrlString(form.value.attachmentFiles),
    };
    loading.value = true;
    const action = reportId.value ? updateInspectionReport : addInspectionReport;
    action(payload)
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 1000);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      reportId.value = options.id;
      form.value.id = options.id;
      loadDetail();
    } else {
      form.value.reportDate = formatDateToYMD(Date.now());
      calculateExpiration();
    }
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .upload-wrap {
    padding: 12px;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/qualityManagement/inspectionReport/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,265 @@
<template>
  <view class="sales-account">
    <PageHeader title="报表管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.productName"
                    placeholder="请输入产品名称搜索"
                    clearable
                    @change="handleSearch" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
      <view v-if="expiringCount > 0"
            class="warn-tip">
        <text class="warn-tip-text">{{ expiringCount }} ä»½æŠ¥å‘Šå·²åˆ°æœŸæˆ–将在 30 å¤©å†…到期</text>
      </view>
    </view>
    <view v-if="reportList.length > 0"
          class="ledger-list">
      <view v-for="item in reportList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="file-text"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.reportNo || "-" }}</text>
          </view>
          <up-tag :text="item.reportType || '未分类'"
                  type="primary"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">产品名称</text>
            <text class="detail-value">{{ item.productName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">产品型号</text>
            <text class="detail-value">{{ item.productModel || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">检测单位</text>
            <text class="detail-value">{{ item.testingAgency || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">报告日期</text>
            <text class="detail-value">{{ item.reportDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">到期时间</text>
            <text class="detail-value">{{ item.expirationTime || "-" }}</text>
          </view>
          <view class="detail-row"
                v-if="warnInfo(item).text">
            <text class="detail-label">有效期状态</text>
            <text class="detail-value"
                  :style="{ color: warnInfo(item).color }">{{ warnInfo(item).text }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">注销</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无报告数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="报告筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    delInspectionReport,
    listInspectionReport,
  } from "@/api/qualityManagement/inspectionReport";
  import { unwrapList } from "@/utils/list";
  import { getWarnInfo } from "@/utils/warning";
  // æŠ¥å‘Šæœ‰æ•ˆæœŸé¢„警阈值(需求十:到期前 30 å¤©ï¼‰
  const WARN_DAYS = 30;
  const reportList = ref([]);
  const showFilter = ref(false);
  const query = reactive({
    productName: "",
    reportNo: "",
    reportType: "",
  });
  const filterFields = [
    {
      prop: "productName",
      label: "产品名称",
      type: "input",
      placeholder: "请输入产品名称",
    },
    {
      prop: "reportNo",
      label: "报告编号",
      type: "input",
      placeholder: "请输入报告编号",
    },
    {
      prop: "reportType",
      label: "报告类型",
      type: "select",
      options: [
        { name: "型式试验报告", value: "型式试验报告" },
        { name: "出厂检验报告", value: "出厂检验报告" },
        { name: "委托检验报告", value: "委托检验报告" },
      ],
    },
  ];
  const warnInfo = item => getWarnInfo(item.expirationTime, WARN_DAYS);
  const expiringCount = computed(
    () =>
      reportList.value.filter(item => {
        const level = warnInfo(item).level;
        return level === "overdue" || level === "warning";
      }).length
  );
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("inspectionReportDetail");
    uni.navigateTo({ url: "/pages/qualityManagement/inspectionReport/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("inspectionReportDetail", item || {});
    uni.navigateTo({
      url: `/pages/qualityManagement/inspectionReport/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("inspectionReportDetail", item || {});
    uni.navigateTo({
      url: `/pages/qualityManagement/inspectionReport/detail?id=${item.id}`,
    });
  };
  const getList = () => {
    listInspectionReport({
      current: -1,
      size: -1,
      productName: query.productName || undefined,
      reportNo: query.reportNo || undefined,
      reportType: query.reportType || undefined,
    })
      .then(res => {
        reportList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  const handleSearch = () => {
    getList();
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "注销确认",
      content: "该报告将被注销作废,是否确认?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delInspectionReport(item.id)
          .then(() => {
            uni.showToast({ title: "注销成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "注销失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .warn-tip {
    margin-top: 10px;
    padding: 8px 12px;
    border-radius: 8px;
    background: #fff5f5;
  }
  .warn-tip-text {
    font-size: 13px;
    color: #f56c6c;
  }
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/sales/biddingManagement/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,246 @@
<template>
  <view class="bidding-detail">
    <PageHeader title="招投标详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <view class="hero-left">
          <text class="hero-title">{{ detail.bidDocName || "-" }}</text>
          <text class="hero-sub">{{ detail.projectName || "-" }}</text>
        </view>
        <up-tag :text="detail.bidResult || '未填结果'"
                :type="resultTagType(detail.bidResult)"
                size="mini"
                shape="circle" />
      </view>
      <view v-if="canStartContract"
            class="contract-entry"
            @click="startContract">
        <up-icon name="plus-circle"
                 size="18"
                 color="#2979ff"></up-icon>
        <text class="contract-entry-text">发起合同录入</text>
      </view>
      <u-cell-group title="项目信息"
                    class="detail-section">
        <u-cell title="所属项目"
                :value="detail.projectName || '-'" />
        <u-cell title="标书名称"
                :value="detail.bidDocName || '-'" />
      </u-cell-group>
      <u-cell-group title="招投标单位与日期"
                    class="detail-section">
        <u-cell title="招标单位"
                :value="detail.tenderUnit || '-'" />
        <u-cell title="投标单位"
                :value="detail.bidUnit || '-'" />
        <u-cell title="招标日期"
                :value="detail.tenderDate || '-'" />
        <u-cell title="投标日期"
                :value="detail.bidDate || '-'" />
      </u-cell-group>
      <u-cell-group title="标书款与保证金"
                    class="detail-section">
        <u-cell title="标书款金额"
                :value="formatAmount(detail.bidDocFee)" />
        <u-cell title="标书款状态"
                :value="detail.bidDocFeeStatus || '-'" />
        <u-cell title="状态变更日期"
                :value="detail.bidDocFeeStatusChangeDate || '-'" />
        <u-cell title="投标保证金"
                :value="formatAmount(detail.bidDeposit)" />
      </u-cell-group>
      <u-cell-group title="招投标结果"
                    class="detail-section">
        <u-cell title="招投标结果"
                :value="detail.bidResult || '-'" />
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { loadDetailData } from "@/utils/detailCache";
  import { setPrefillProject } from "@/utils/prefill";
  import { getBiddingDetail } from "@/api/salesManagement/biddingManagement";
  // éœ€æ±‚三.6:结果命中这三种时可直接发起销售合同录入
  const CONTRACT_TRIGGER_RESULTS = [
    "中标待出通知书",
    "已出中标通知书",
    "已签合同",
  ];
  const loading = ref(true);
  const biddingId = ref("");
  const detail = ref({});
  const canStartContract = computed(() =>
    CONTRACT_TRIGGER_RESULTS.includes(detail.value?.bidResult)
  );
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("biddingDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/sales/biddingManagement/edit?id=${biddingId.value}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const resultTagType = result => {
    if (result === "已签合同") return "success";
    if (result === "废标" || result === "流标" || result === "未中标") {
      return "error";
    }
    if (result) return "warning";
    return "info";
  };
  const startContract = () => {
    setPrefillProject({
      projectId: detail.value.projectId,
      projectName: detail.value.projectName,
    });
    uni.setStorageSync("operationType", "add");
    uni.removeStorageSync("editData");
    uni.navigateTo({ url: "/pages/sales/salesAccount/detail" });
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      const data = await loadDetailData({
        cacheKey: "biddingDetail",
        id: biddingId.value,
        fetcher: getBiddingDetail,
      });
      detail.value = data || {};
    } catch (error) {
      uni.showToast({ title: "获取招投标详情失败", icon: "none" });
      detail.value = {};
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    biddingId.value = options?.id || "";
    if (!biddingId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .bidding-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-left {
    display: flex;
    flex-direction: column;
    gap: 6px;
    max-width: 70%;
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    word-break: break-all;
  }
  .hero-sub {
    font-size: 13px;
    color: #606266;
    word-break: break-all;
  }
  .contract-entry {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    margin-top: 12px;
    padding: 11px 0;
    border-radius: 10px;
    border: 1px dashed #2979ff;
    background: #f4f8ff;
  }
  .contract-entry-text {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/biddingManagement/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,337 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="项目信息"
                      class="form-section">
          <up-form-item label="所属项目"
                        prop="projectId"
                        required
                        border-bottom>
            <FormPicker v-model="form.projectId"
                        :display-text="form.projectName"
                        type="select"
                        title="选择所属项目"
                        :options="projectOptions"
                        placeholder="请选择所属项目"
                        @change="onProjectChange" />
          </up-form-item>
          <up-form-item label="标书名称"
                        prop="bidDocName"
                        required
                        border-bottom>
            <up-input v-model="form.bidDocName"
                      placeholder="请输入标书名称"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="招投标单位与日期"
                      class="form-section">
          <up-form-item label="招标单位"
                        prop="tenderUnit"
                        required
                        border-bottom>
            <up-input v-model="form.tenderUnit"
                      placeholder="请输入招标单位"
                      clearable />
          </up-form-item>
          <up-form-item label="投标单位"
                        prop="bidUnit"
                        required
                        border-bottom>
            <up-input v-model="form.bidUnit"
                      placeholder="请输入投标单位"
                      clearable />
          </up-form-item>
          <up-form-item label="招标日期"
                        prop="tenderDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.tenderDate"
                        type="date"
                        placeholder="请选择招标日期" />
          </up-form-item>
          <up-form-item label="投标日期"
                        prop="bidDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.bidDate"
                        type="date"
                        placeholder="请选择投标日期" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="标书款与保证金"
                      class="form-section">
          <up-form-item label="标书款金额"
                        prop="bidDocFee"
                        border-bottom>
            <up-input v-model="form.bidDocFee"
                      type="number"
                      placeholder="请输入标书款金额"
                      clearable />
          </up-form-item>
          <up-form-item label="标书款状态"
                        prop="bidDocFeeStatus"
                        required
                        border-bottom>
            <FormPicker v-model="form.bidDocFeeStatus"
                        type="select"
                        title="选择标书款状态"
                        :options="bidDocFeeStatusOptions"
                        placeholder="请选择标书款状态"
                        @change="onFeeStatusChange" />
          </up-form-item>
          <up-form-item label="状态变更日期"
                        prop="bidDocFeeStatusChangeDate"
                        border-bottom>
            <FormPicker v-model="form.bidDocFeeStatusChangeDate"
                        type="date"
                        placeholder="状态变更时自动填当前日期" />
          </up-form-item>
          <up-form-item label="投标保证金"
                        prop="bidDeposit"
                        border-bottom>
            <up-input v-model="form.bidDeposit"
                      type="number"
                      placeholder="请输入投标保证金金额"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="招投标结果"
                      class="form-section">
          <up-form-item label="招投标结果"
                        prop="bidResult"
                        required
                        border-bottom>
            <FormPicker v-model="form.bidResult"
                        type="select"
                        title="选择招投标结果"
                        :options="bidResultOptions"
                        placeholder="请选择招投标结果" />
          </up-form-item>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { loadDetailData } from "@/utils/detailCache";
  import { loadProjectOptions, pickProjectName } from "@/utils/projectOptions";
  import {
    addOrUpdateBidding,
    getBiddingDetail,
  } from "@/api/salesManagement/biddingManagement";
  const formRef = ref();
  const loading = ref(false);
  const biddingId = ref("");
  const projectOptions = ref([]);
  const bidDocFeeStatusOptions = [
    { name: "未交", value: "未交" },
    { name: "已交", value: "已交" },
    { name: "已退", value: "已退" },
  ];
  const bidResultOptions = [
    { name: "废标", value: "废标" },
    { name: "流标", value: "流标" },
    { name: "未中标", value: "未中标" },
    { name: "中标待出通知书", value: "中标待出通知书" },
    { name: "已出中标通知书", value: "已出中标通知书" },
    { name: "已签合同", value: "已签合同" },
  ];
  const form = ref({
    id: undefined,
    projectId: "",
    projectName: "",
    bidDocName: "",
    tenderUnit: "",
    bidUnit: "",
    tenderDate: "",
    bidDate: "",
    bidDocFee: "",
    bidDocFeeStatus: "",
    bidDocFeeStatusChangeDate: "",
    bidDeposit: "",
    bidResult: "",
  });
  const rules = {
    projectId: [{ required: true, message: "请选择所属项目", trigger: "change" }],
    bidDocName: [{ required: true, message: "请输入标书名称", trigger: "blur" }],
    tenderUnit: [{ required: true, message: "请输入招标单位", trigger: "blur" }],
    bidUnit: [{ required: true, message: "请输入投标单位", trigger: "blur" }],
    tenderDate: [{ required: true, message: "请选择招标日期", trigger: "change" }],
    bidDate: [{ required: true, message: "请选择投标日期", trigger: "change" }],
    bidDocFeeStatus: [
      { required: true, message: "请选择标书款状态", trigger: "change" },
    ],
    bidResult: [{ required: true, message: "请选择招投标结果", trigger: "change" }],
  };
  const pageTitle = computed(() =>
    biddingId.value ? "编辑招投标" : "新增招投标"
  );
  const goBack = () => uni.navigateBack();
  const numOrNull = value =>
    value === "" || value === null || value === undefined
      ? null
      : Number.isNaN(Number(value))
        ? null
        : Number(value);
  const onProjectChange = (value, item) => {
    form.value.projectName = pickProjectName(item);
  };
  // æ ‡ä¹¦æ¬¾çŠ¶æ€å˜æ›´ä¸”æ—¥æœŸä¸ºç©ºæ—¶è‡ªåŠ¨å¡«å½“å¤©
  const onFeeStatusChange = () => {
    if (form.value.bidDocFeeStatus && !form.value.bidDocFeeStatusChangeDate) {
      form.value.bidDocFeeStatusChangeDate = formatDateToYMD(Date.now());
    }
  };
  const loadOptions = async () => {
    try {
      projectOptions.value = await loadProjectOptions();
    } catch (error) {
      projectOptions.value = [];
    }
  };
  const loadDetail = async () => {
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "biddingDetail",
        id: biddingId.value,
        fetcher: getBiddingDetail,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "未找到招投标信息", icon: "none" });
      return;
    }
    form.value = {
      id: data.id,
      projectId: data.projectId ?? "",
      projectName: data.projectName || "",
      bidDocName: data.bidDocName || "",
      tenderUnit: data.tenderUnit || "",
      bidUnit: data.bidUnit || "",
      tenderDate: (data.tenderDate || "").slice(0, 10),
      bidDate: (data.bidDate || "").slice(0, 10),
      bidDocFee: data.bidDocFee ?? "",
      bidDocFeeStatus: data.bidDocFeeStatus || "",
      bidDocFeeStatusChangeDate: (
        data.bidDocFeeStatusChangeDate || ""
      ).slice(0, 10),
      bidDeposit: data.bidDeposit ?? "",
      bidResult: data.bidResult || "",
    };
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    const payload = {
      id: form.value.id,
      projectId: form.value.projectId,
      projectName: form.value.projectName,
      bidDocName: form.value.bidDocName,
      tenderUnit: form.value.tenderUnit,
      bidUnit: form.value.bidUnit,
      tenderDate: form.value.tenderDate,
      bidDate: form.value.bidDate,
      bidDocFee: numOrNull(form.value.bidDocFee),
      bidDocFeeStatus: form.value.bidDocFeeStatus,
      bidDocFeeStatusChangeDate: form.value.bidDocFeeStatusChangeDate || null,
      bidDeposit: numOrNull(form.value.bidDeposit),
      bidResult: form.value.bidResult,
    };
    loading.value = true;
    addOrUpdateBidding(payload)
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 1000);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    loadOptions();
    if (options?.id) {
      biddingId.value = options.id;
      form.value.id = options.id;
      loadDetail();
    } else {
      form.value.tenderDate = formatDateToYMD(Date.now());
      form.value.bidDate = formatDateToYMD(Date.now());
    }
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/biddingManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,305 @@
<template>
  <view class="sales-account">
    <PageHeader title="招投标管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.projectName"
                    placeholder="请输入项目名称搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="biddingList.length > 0"
          class="ledger-list">
      <view v-for="item in biddingList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="file-text"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.bidDocName || "-" }}</text>
          </view>
          <up-tag :text="item.bidResult || '未填结果'"
                  :type="resultTagType(item.bidResult)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">所属项目</text>
            <text class="detail-value">{{ item.projectName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">招标单位</text>
            <text class="detail-value">{{ item.tenderUnit || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">投标单位</text>
            <text class="detail-value">{{ item.bidUnit || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">招标日期</text>
            <text class="detail-value">{{ item.tenderDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">投标日期</text>
            <text class="detail-value">{{ item.bidDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">标书款</text>
            <text class="detail-value">{{ formatAmount(item.bidDocFee) }}({{ item.bidDocFeeStatus || "-" }})</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">投标保证金</text>
            <text class="detail-value">{{ formatAmount(item.bidDeposit) }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
        <view v-if="canStartContract(item)"
              class="contract-entry"
              @click="startContract(item)">
          <up-icon name="plus-circle"
                   size="16"
                   color="#2979ff"></up-icon>
          <text class="contract-entry-text">发起合同录入</text>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无招投标数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="招投标筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    biddingListPage,
    delBidding,
  } from "@/api/salesManagement/biddingManagement";
  import { unwrapList } from "@/utils/list";
  import { setPrefillProject } from "@/utils/prefill";
  // éœ€æ±‚三.6:结果命中这三种时可直接发起销售合同录入
  const CONTRACT_TRIGGER_RESULTS = [
    "中标待出通知书",
    "已出中标通知书",
    "已签合同",
  ];
  const BID_RESULT_OPTIONS = [
    { name: "废标", value: "废标" },
    { name: "流标", value: "流标" },
    { name: "未中标", value: "未中标" },
    { name: "中标待出通知书", value: "中标待出通知书" },
    { name: "已出中标通知书", value: "已出中标通知书" },
    { name: "已签合同", value: "已签合同" },
  ];
  const biddingList = ref([]);
  const showFilter = ref(false);
  const query = reactive({
    projectName: "",
    tenderUnit: "",
    bidResult: "",
    bidDateRange: [],
  });
  const filterFields = [
    {
      prop: "projectName",
      label: "项目名称",
      type: "input",
      placeholder: "请输入项目名称",
    },
    {
      prop: "tenderUnit",
      label: "招标单位",
      type: "input",
      placeholder: "请输入招标单位",
    },
    {
      prop: "bidResult",
      label: "招投标结果",
      type: "select",
      options: BID_RESULT_OPTIONS,
    },
    { prop: "bidDateRange", label: "投标日期", type: "daterange" },
  ];
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("biddingDetail");
    uni.navigateTo({ url: "/pages/sales/biddingManagement/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("biddingDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/biddingManagement/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("biddingDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/biddingManagement/detail?id=${item.id}`,
    });
  };
  const canStartContract = item =>
    CONTRACT_TRIGGER_RESULTS.includes(item?.bidResult);
  // éœ€æ±‚三.6:中标后直接生成一条销售台账,带项目/客户信息跳新增页
  const startContract = item => {
    setPrefillProject({
      projectId: item.projectId,
      projectName: item.projectName,
    });
    uni.setStorageSync("operationType", "add");
    uni.removeStorageSync("editData");
    uni.navigateTo({ url: "/pages/sales/salesAccount/detail" });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const resultTagType = result => {
    if (result === "已签合同") return "success";
    if (result === "废标" || result === "流标" || result === "未中标") {
      return "error";
    }
    if (result) return "warning";
    return "info";
  };
  const getList = () => {
    const [bidDateStart, bidDateEnd] = Array.isArray(query.bidDateRange)
      ? query.bidDateRange
      : [];
    biddingListPage({
      current: -1,
      size: -1,
      projectName: query.projectName || undefined,
      tenderUnit: query.tenderUnit || undefined,
      bidResult: query.bidResult || undefined,
      bidDateStart: bidDateStart || undefined,
      bidDateEnd: bidDateEnd || undefined,
    })
      .then(res => {
        biddingList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该招投标记录吗?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delBidding(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
  .contract-entry {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    margin: 0 12px 12px;
    padding: 9px 0;
    border-radius: 8px;
    border: 1px dashed #2979ff;
    background: #f4f8ff;
  }
  .contract-entry-text {
    font-size: 13px;
    color: #2979ff;
  }
</style>
src/pages/sales/contractManagement/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,304 @@
<template>
  <view class="contract-detail">
    <PageHeader title="合同详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <text class="hero-title">{{ detail.contractNo || "-" }}</text>
        <up-tag :text="detail.archiveStatus || '未填状态'"
                :type="archiveTagType(detail.archiveStatus)"
                size="mini"
                shape="circle" />
      </view>
      <u-cell-group title="合同主档"
                    class="detail-section">
        <u-cell title="合同编号"
                :value="detail.contractNo || '-'" />
        <u-cell title="签订时间"
                :value="detail.signDate || '-'" />
        <u-cell title="签约单位简称"
                :value="detail.signingUnit || '-'" />
        <u-cell title="签约周期"
                :value="detail.contractPeriod || '-'" />
        <u-cell title="存档状态"
                :value="detail.archiveStatus || '-'" />
        <u-cell title="存档情况说明"
                :value="detail.archiveRemark || '-'" />
      </u-cell-group>
      <u-cell-group title="产品信息"
                    class="detail-section">
        <view v-if="productList.length === 0"
              class="empty-text">
          <text>暂无产品明细</text>
        </view>
        <view v-else
              class="product-list">
          <view v-for="(row, index) in productList"
                :key="index"
                class="product-card">
            <view class="product-head">产品 {{ index + 1 }}</view>
            <view class="info-item">
              <text class="info-label">产品大类</text>
              <text class="info-value">{{ row.productCategory || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">规格型号</text>
              <text class="info-value">{{ row.specificationModel || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">合同计量单位</text>
              <text class="info-value">{{ row.contractUnit || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">含税单价</text>
              <text class="info-value">{{ formatAmount(row.taxInclusiveUnitPrice) }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">约定/暂估用量</text>
              <text class="info-value">{{ row.agreedQuantity ?? "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">已发货数量</text>
              <text class="info-value">{{ row.shippedQuantity ?? "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">产值</text>
              <text class="info-value highlight">{{ formatAmount(row.outputValue) }}</text>
            </view>
          </view>
        </view>
      </u-cell-group>
      <u-cell-group title="履约与质保"
                    class="detail-section">
        <u-cell title="履约保证金"
                :value="formatAmount(detail.performanceBond)" />
        <u-cell title="质保金情况"
                :value="formatAmount(detail.warrantyDeposit)" />
        <u-cell title="产值"
                :value="formatAmount(detail.outputValue)" />
      </u-cell-group>
      <u-cell-group title="回款与结算"
                    class="detail-section">
        <u-cell title="已回款金额"
                :value="formatAmount(detail.receivedAmount)" />
        <u-cell title="结算次数"
                :value="detail.settlementCount ?? '-'" />
        <u-cell title="应收金额"
                :value="formatAmount(detail.receivableAmount)" />
        <u-cell title="应付比例"
                :value="detail.payableRatio || '-'" />
        <u-cell title="合同节点应收"
                :value="formatAmount(detail.nodeReceivable)" />
        <u-cell title="合同约定付款节点"
                :value="detail.paymentNodes || '-'" />
        <u-cell title="结算记录"
                :value="detail.settlementRecord || '-'" />
      </u-cell-group>
      <u-cell-group title="开票与违约"
                    class="detail-section">
        <u-cell title="开票要求"
                :value="detail.invoiceRequirement || '-'" />
        <u-cell title="最近一次开票时间"
                :value="detail.lastInvoiceDate || '-'" />
        <u-cell title="已开票金额"
                :value="formatAmount(detail.invoicedAmount)" />
        <u-cell title="违约金约定"
                :value="detail.penaltyAgreement || '-'" />
        <u-cell title="情况说明"
                :value="detail.remark || '-'" />
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { loadDetailData } from "@/utils/detailCache";
  import { getContractDetail } from "@/api/salesManagement/contractManagement";
  const loading = ref(true);
  const contractId = ref("");
  const detail = ref({});
  const productList = computed(() =>
    Array.isArray(detail.value.productList) ? detail.value.productList : []
  );
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("contractDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/sales/contractManagement/edit?id=${contractId.value}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const archiveTagType = status => {
    if (status === "已存档") return "success";
    if (status === "已用章未寄回") return "warning";
    return "info";
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      const data = await loadDetailData({
        cacheKey: "contractDetail",
        id: contractId.value,
        fetcher: getContractDetail,
      });
      detail.value = data || {};
    } catch (error) {
      uni.showToast({ title: "获取合同详情失败", icon: "none" });
      detail.value = {};
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    contractId.value = options?.id || "";
    if (!contractId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .contract-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .empty-text {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .product-list {
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .product-card {
    background: #f9fafc;
    border-radius: 10px;
    overflow: hidden;
  }
  .product-head {
    padding: 12px 16px;
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
    border-bottom: 1px solid #eef2f7;
  }
  .info-item {
    display: flex;
    padding: 10px 16px;
    border-bottom: 1px solid #f8f8f8;
  }
  .info-item:last-child {
    border-bottom: none;
  }
  .info-label {
    width: 120px;
    font-size: 14px;
    color: #606266;
  }
  .info-value {
    flex: 1;
    font-size: 14px;
    color: #303133;
    text-align: right;
    word-break: break-all;
  }
  .highlight {
    color: #2979ff;
    font-weight: 600;
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/contractManagement/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,628 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="合同主档"
                      class="form-section">
          <up-form-item label="合同编号"
                        prop="contractNo"
                        required>
            <up-input v-model="form.contractNo"
                      placeholder="请输入合同编号"
                      clearable />
          </up-form-item>
          <up-form-item label="签订时间"
                        prop="signDate"
                        required>
            <FormPicker v-model="form.signDate"
                        type="date"
                        placeholder="请选择签订时间" />
          </up-form-item>
          <up-form-item label="签约单位简称"
                        prop="signingUnit"
                        required>
            <up-input v-model="form.signingUnit"
                      placeholder="请输入签约单位简称"
                      clearable />
          </up-form-item>
          <up-form-item label="签约周期"
                        prop="contractPeriod">
            <up-input v-model="form.contractPeriod"
                      placeholder="请输入签约周期"
                      clearable />
          </up-form-item>
          <up-form-item label="存档状态"
                        prop="archiveStatus"
                        required>
            <FormPicker v-model="form.archiveStatus"
                        type="select"
                        title="选择存档状态"
                        :options="ARCHIVE_STATUS_OPTIONS"
                        placeholder="请选择存档状态" />
          </up-form-item>
          <up-form-item label="存档情况说明"
                        prop="archiveRemark">
            <up-input v-model="form.archiveRemark"
                      placeholder="请输入存档情况说明"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="产品信息"
                      class="form-section">
          <view class="section-tools">
            <up-button type="primary"
                       size="small"
                       text="添加产品"
                       @click="addProduct" />
          </view>
          <view v-if="productList.length === 0"
                class="empty-text">
            <text>暂无产品,请先添加产品</text>
          </view>
          <view v-else
                class="product-list">
            <view v-for="(row, index) in productList"
                  :key="row.uid"
                  class="product-card">
              <view class="product-header">
                <text class="product-title">产品 {{ index + 1 }}</text>
                <up-icon name="trash"
                         color="#ee0a24"
                         size="18"
                         @click="removeProduct(index)"></up-icon>
              </view>
              <up-divider></up-divider>
              <view class="product-body">
                <up-form-item label="产品大类">
                  <up-input v-model="row.productCategory"
                            placeholder="请选择产品大类"
                            readonly
                            @click="openCategoryPicker(index)" />
                  <template #right>
                    <up-icon name="arrow-right"
                             @click="openCategoryPicker(index)"></up-icon>
                  </template>
                </up-form-item>
                <up-form-item label="规格型号">
                  <up-input v-model="row.specificationModel"
                            placeholder="请选择规格型号"
                            readonly
                            @click="openModelPicker(index)" />
                  <template #right>
                    <up-icon name="arrow-right"
                             @click="openModelPicker(index)"></up-icon>
                  </template>
                </up-form-item>
                <up-form-item label="合同计量单位">
                  <up-input v-model="row.contractUnit"
                            placeholder="请输入计量单位"
                            clearable />
                </up-form-item>
                <up-form-item label="含税单价">
                  <up-input v-model="row.taxInclusiveUnitPrice"
                            type="number"
                            placeholder="请输入含税单价"
                            clearable />
                </up-form-item>
                <up-form-item label="约定/暂估用量">
                  <up-input v-model="row.agreedQuantity"
                            type="number"
                            placeholder="请输入约定用量"
                            clearable />
                </up-form-item>
                <up-form-item label="已发货数量">
                  <up-input v-model="row.shippedQuantity"
                            type="number"
                            placeholder="请输入已发货数量"
                            clearable />
                </up-form-item>
                <up-form-item label="产值">
                  <up-input :model-value="formatAmount(productOutputValue(row))"
                            disabled
                            placeholder="自动计算" />
                </up-form-item>
              </view>
            </view>
          </view>
        </u-cell-group>
        <u-cell-group title="履约与质保"
                      class="form-section">
          <up-form-item label="履约保证金">
            <up-input v-model="form.performanceBond"
                      type="number"
                      placeholder="请输入履约保证金"
                      clearable />
          </up-form-item>
          <up-form-item label="质保金情况">
            <up-input v-model="form.warrantyDeposit"
                      type="number"
                      placeholder="请输入质保金"
                      clearable />
          </up-form-item>
          <up-form-item label="产值">
            <up-input :model-value="formatAmount(computedOutputValue)"
                      disabled
                      placeholder="自动汇总" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="回款与结算"
                      class="form-section">
          <up-form-item label="已回款金额">
            <up-input v-model="form.receivedAmount"
                      type="number"
                      placeholder="请输入已回款金额"
                      clearable />
          </up-form-item>
          <up-form-item label="结算次数">
            <up-input v-model="form.settlementCount"
                      type="number"
                      placeholder="请输入结算次数"
                      clearable />
          </up-form-item>
          <up-form-item label="应收金额">
            <up-input v-model="form.receivableAmount"
                      type="number"
                      placeholder="请输入应收金额"
                      clearable />
          </up-form-item>
          <up-form-item label="应付比例">
            <up-input v-model="form.payableRatio"
                      placeholder="例如:30% æˆ– 0.3"
                      clearable />
          </up-form-item>
          <up-form-item label="合同节点应收">
            <up-input v-model="form.nodeReceivable"
                      type="number"
                      placeholder="请输入合同节点应收"
                      clearable />
          </up-form-item>
          <up-form-item label="约定付款节点">
            <up-input v-model="form.paymentNodes"
                      placeholder="请输入合同约定付款节点"
                      clearable />
          </up-form-item>
          <up-form-item label="结算记录">
            <up-textarea v-model="form.settlementRecord"
                         placeholder="请输入结算记录"
                         auto-height />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="开票与违约"
                      class="form-section">
          <up-form-item label="开票要求">
            <up-input v-model="form.invoiceRequirement"
                      placeholder="请输入开票要求"
                      clearable />
          </up-form-item>
          <up-form-item label="最近开票时间">
            <FormPicker v-model="form.lastInvoiceDate"
                        type="date"
                        placeholder="请选择最近一次开票时间" />
          </up-form-item>
          <up-form-item label="已开票金额">
            <up-input v-model="form.invoicedAmount"
                      type="number"
                      placeholder="请输入已开票金额"
                      clearable />
          </up-form-item>
          <up-form-item label="违约金约定">
            <up-textarea v-model="form.penaltyAgreement"
                         placeholder="请输入违约金约定"
                         auto-height />
          </up-form-item>
          <up-form-item label="情况说明">
            <up-textarea v-model="form.remark"
                         placeholder="请输入情况说明"
                         auto-height />
          </up-form-item>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
    <up-action-sheet :show="showCategorySheet"
                     title="选择产品大类"
                     :actions="categoryActions"
                     @select="onSelectCategory"
                     @close="showCategorySheet = false" />
    <up-action-sheet :show="showModelSheet"
                     title="选择规格型号"
                     :actions="modelActions"
                     @select="onSelectModel"
                     @close="showModelSheet = false" />
  </view>
</template>
<script setup>
  import { computed, onMounted, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import FooterButtons from "@/components/FooterButtons.vue";
  import PageHeader from "@/components/PageHeader.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { modelList, productTreeList } from "@/api/basicData/product";
  import { loadDetailData } from "@/utils/detailCache";
  import {
    addOrUpdateContract,
    getContractDetail,
  } from "@/api/salesManagement/contractManagement";
  const ARCHIVE_STATUS_OPTIONS = [
    { name: "已存档", value: "已存档" },
    { name: "已用章未寄回", value: "已用章未寄回" },
    { name: "未签约", value: "未签约" },
  ];
  const formRef = ref();
  const loading = ref(false);
  const contractId = ref("");
  const productList = ref([]);
  const categoryOptions = ref([]);
  const categoryActions = ref([]);
  const modelActions = ref([]);
  const showCategorySheet = ref(false);
  const showModelSheet = ref(false);
  const currentProductIndex = ref(-1);
  let uidSeed = 1;
  const form = ref({
    id: undefined,
    contractNo: "",
    signDate: "",
    signingUnit: "",
    contractPeriod: "",
    archiveStatus: "",
    archiveRemark: "",
    performanceBond: undefined,
    warrantyDeposit: undefined,
    outputValue: undefined,
    receivedAmount: undefined,
    settlementCount: undefined,
    receivableAmount: undefined,
    payableRatio: "",
    nodeReceivable: undefined,
    paymentNodes: "",
    settlementRecord: "",
    invoiceRequirement: "",
    lastInvoiceDate: "",
    invoicedAmount: undefined,
    penaltyAgreement: "",
    remark: "",
  });
  const rules = {
    contractNo: [{ required: true, message: "请输入合同编号", trigger: "blur" }],
    signDate: [{ required: true, message: "请选择签订时间", trigger: "change" }],
    signingUnit: [
      { required: true, message: "请输入签约单位简称", trigger: "blur" },
    ],
    archiveStatus: [
      { required: true, message: "请选择存档状态", trigger: "change" },
    ],
  };
  const pageTitle = computed(() => (contractId.value ? "编辑合同" : "新增合同"));
  const productOutputValue = row =>
    Number(
      (
        Number(row?.taxInclusiveUnitPrice || 0) *
        Number(row?.shippedQuantity || 0)
      ).toFixed(2)
    );
  const computedOutputValue = computed(() =>
    Number(
      productList.value
        .reduce((sum, row) => sum + productOutputValue(row), 0)
        .toFixed(2)
    )
  );
  const formatAmount = amount => `Â¥${Number(amount || 0).toFixed(2)}`;
  const goBack = () => uni.navigateBack();
  const flattenProductTree = nodes => {
    const result = [];
    const walk = list => {
      (list || []).forEach(item => {
        if (item.children && item.children.length) {
          walk(item.children);
        } else {
          result.push({
            label: item.label || item.productName || "",
            value: item.id || item.value,
          });
        }
      });
    };
    walk(nodes);
    return result;
  };
  const findNodeIdByLabel = (nodes, label) => {
    if (!label) return null;
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i];
      if (node.label === label) return node.value;
      if (node.children && node.children.length > 0) {
        const found = findNodeIdByLabel(node.children, label);
        if (found !== null && found !== undefined) return found;
      }
    }
    return null;
  };
  const createEmptyProduct = () => ({
    uid: `p_${uidSeed++}`,
    categoryId: "",
    productCategory: "",
    productModelId: "",
    specificationModel: "",
    contractUnit: "",
    taxInclusiveUnitPrice: undefined,
    agreedQuantity: undefined,
    shippedQuantity: undefined,
    modelOptions: [],
  });
  const addProduct = () => productList.value.push(createEmptyProduct());
  const removeProduct = index => {
    productList.value.splice(index, 1);
  };
  const fetchBaseOptions = async () => {
    try {
      const res = await productTreeList();
      const nodes = Array.isArray(res) ? res : res?.data || [];
      categoryOptions.value = flattenProductTree(nodes);
    } catch (error) {
      categoryOptions.value = [];
    }
  };
  const fetchModelOptions = async (categoryId, row) => {
    try {
      const res = await modelList({ id: categoryId });
      const rows = res?.data?.records || res?.data || res?.records || res || [];
      row.modelOptions = Array.isArray(rows) ? rows : [];
    } catch (error) {
      row.modelOptions = [];
    }
  };
  const openCategoryPicker = index => {
    currentProductIndex.value = index;
    categoryActions.value = categoryOptions.value.map(item => ({
      name: item.label,
      value: item.value,
      label: item.label,
    }));
    showCategorySheet.value = true;
  };
  const openModelPicker = index => {
    currentProductIndex.value = index;
    const row = productList.value[index];
    if (!row?.categoryId) {
      uni.showToast({ title: "请先选择产品大类", icon: "none" });
      return;
    }
    modelActions.value = (row.modelOptions || []).map(item => ({
      name: item.model || item.specification,
      value: item.id,
      unit: item.unit,
    }));
    if (!modelActions.value.length) {
      uni.showToast({ title: "暂无规格型号", icon: "none" });
      return;
    }
    showModelSheet.value = true;
  };
  const onSelectCategory = action => {
    const row = productList.value[currentProductIndex.value];
    showCategorySheet.value = false;
    if (!row) return;
    row.categoryId = action.value;
    row.productCategory = action.label;
    row.productModelId = "";
    row.specificationModel = "";
    row.contractUnit = "";
    row.modelOptions = [];
    fetchModelOptions(action.value, row);
  };
  const onSelectModel = action => {
    const row = productList.value[currentProductIndex.value];
    showModelSheet.value = false;
    if (!row) return;
    row.productModelId = action.value;
    row.specificationModel = action.name;
    row.contractUnit = action.unit || row.contractUnit;
  };
  const normalizeProductRows = async rows => {
    const normalized = await Promise.all(
      (Array.isArray(rows) ? rows : []).map(async item => {
        const row = {
          uid: `p_${uidSeed++}`,
          categoryId:
            item.categoryId ||
            findNodeIdByLabel(categoryOptions.value, item.productCategory) ||
            "",
          productCategory: item.productCategory || "",
          productModelId: item.productModelId || "",
          specificationModel: item.specificationModel || "",
          contractUnit: item.contractUnit || "",
          taxInclusiveUnitPrice: item.taxInclusiveUnitPrice,
          agreedQuantity: item.agreedQuantity,
          shippedQuantity: item.shippedQuantity,
          modelOptions: [],
        };
        if (row.categoryId) {
          await fetchModelOptions(row.categoryId, row);
          // ä»…存了型号名称时,从型号列表反查 id,保证选择器能反显
          if (!row.productModelId && row.specificationModel) {
            const matched = row.modelOptions.find(
              m => (m.model || m.specification) === row.specificationModel
            );
            if (matched) row.productModelId = matched.id;
          }
        }
        return row;
      })
    );
    productList.value = normalized;
  };
  const loadDetail = async () => {
    if (!contractId.value) return;
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "contractDetail",
        id: contractId.value,
        fetcher: getContractDetail,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "获取合同详情失败", icon: "none" });
      return;
    }
    const { productList: rows, ...rest } = data;
    form.value = { ...form.value, ...rest, id: data.id };
    await normalizeProductRows(rows);
  };
  const buildPayload = () => ({
    ...form.value,
    outputValue: computedOutputValue.value,
    productList: productList.value.map(row => ({
      productCategory: row.productCategory,
      specificationModel: row.specificationModel,
      productModelId: row.productModelId,
      contractUnit: row.contractUnit,
      taxInclusiveUnitPrice: Number(row.taxInclusiveUnitPrice || 0),
      agreedQuantity: Number(row.agreedQuantity || 0),
      shippedQuantity: Number(row.shippedQuantity || 0),
      outputValue: productOutputValue(row),
    })),
  });
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    loading.value = true;
    addOrUpdateContract(buildPayload())
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 300);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      contractId.value = options.id;
      form.value.id = options.id;
    }
  });
  onMounted(async () => {
    await fetchBaseOptions();
    if (contractId.value) await loadDetail();
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .section-tools {
    display: flex;
    justify-content: flex-end;
    padding: 12px 12px 0;
  }
  .product-list {
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .product-card {
    background: #fff;
    border-radius: 12px;
    padding: 0 12px 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .product-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 0;
  }
  .product-title {
    font-size: 14px;
    font-weight: 600;
    color: #22324d;
  }
  .empty-text {
    padding: 16px 12px;
    color: #999;
    font-size: 14px;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/contractManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,279 @@
<template>
  <view class="sales-account">
    <PageHeader title="合同管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.contractNo"
                    placeholder="请输入合同编号搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="contractList.length > 0"
          class="ledger-list">
      <view v-for="item in contractList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="file-text"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.contractNo || "-" }}</text>
          </view>
          <up-tag :text="item.archiveStatus || '未填状态'"
                  :type="archiveTagType(item.archiveStatus)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">签约日期</text>
            <text class="detail-value">{{ item.signDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">签约单位</text>
            <text class="detail-value">{{ item.signingUnit || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">签约周期</text>
            <text class="detail-value">{{ item.contractPeriod || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">产值</text>
            <text class="detail-value highlight">{{ formatAmount(item.outputValue) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">已收款</text>
            <text class="detail-value">{{ formatAmount(item.receivedAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">应收金额</text>
            <text class="detail-value">{{ formatAmount(item.receivableAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">履约保证金</text>
            <text class="detail-value">{{ formatAmount(item.performanceBond) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">质保金</text>
            <text class="detail-value">{{ formatAmount(item.warrantyDeposit) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">结算单数量</text>
            <text class="detail-value">{{ item.settlementCount ?? "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">应收款比例</text>
            <text class="detail-value">{{ item.payableRatio || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">最近开票日期</text>
            <text class="detail-value">{{ item.lastInvoiceDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">已开票金额</text>
            <text class="detail-value">{{ formatAmount(item.invoicedAmount) }}</text>
          </view>
          <view class="detail-row"
                v-if="item.remark">
            <text class="detail-label">情况说明</text>
            <text class="detail-value">{{ item.remark }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无合同数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="合同筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    contractListPage,
    delContract,
  } from "@/api/salesManagement/contractManagement";
  import { unwrapList } from "@/utils/list";
  const ARCHIVE_STATUS_OPTIONS = [
    { name: "已存档", value: "已存档" },
    { name: "已用章未寄回", value: "已用章未寄回" },
    { name: "未签约", value: "未签约" },
  ];
  const contractList = ref([]);
  const showFilter = ref(false);
  const query = reactive({
    contractNo: "",
    signingUnit: "",
    archiveStatus: "",
    signDateRange: [],
  });
  const filterFields = [
    {
      prop: "contractNo",
      label: "合同编号",
      type: "input",
      placeholder: "请输入合同编号",
    },
    {
      prop: "signingUnit",
      label: "签约单位",
      type: "input",
      placeholder: "请输入签约单位",
    },
    {
      prop: "archiveStatus",
      label: "存档状态",
      type: "select",
      options: ARCHIVE_STATUS_OPTIONS,
    },
    { prop: "signDateRange", label: "签约日期", type: "daterange" },
  ];
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("contractDetail");
    uni.navigateTo({ url: "/pages/sales/contractManagement/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("contractDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/contractManagement/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("contractDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/contractManagement/detail?id=${item.id}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const archiveTagType = status => {
    if (status === "已存档") return "success";
    if (status === "已用章未寄回") return "warning";
    if (status === "未签约") return "info";
    return "info";
  };
  const getList = () => {
    const [signDateStart, signDateEnd] = Array.isArray(query.signDateRange)
      ? query.signDateRange
      : [];
    contractListPage({
      current: -1,
      size: -1,
      contractNo: query.contractNo || undefined,
      signingUnit: query.signingUnit || undefined,
      archiveStatus: query.archiveStatus || undefined,
      signDateStart: signDateStart || undefined,
      signDateEnd: signDateEnd || undefined,
    })
      .then(res => {
        contractList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该合同吗?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delContract(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/sales/deliveryLedger/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,405 @@
<template>
  <view class="delivery-detail">
    <PageHeader title="发货详情"
                @back="goBack">
      <template #right>
        <text v-if="!locked"
              class="header-action"
              @click="goEdit">补充信息</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <view class="hero-left">
          <text class="hero-title">{{ detail.shippingNo || detail.salesContractNo || "-" }}</text>
          <text class="hero-sub">{{ detail.projectName || "-" }}</text>
        </view>
        <up-tag :text="statusText(detail.status)"
                :type="statusTagType(detail.status)"
                size="mini"
                shape="circle" />
      </view>
      <view v-if="locked"
            class="lock-banner">
        <text class="lock-banner-text">当前状态已进入正式流程,发货信息不可修改</text>
      </view>
      <u-cell-group title="合同与客户"
                    class="detail-section">
        <u-cell title="销售合同号"
                :value="detail.salesContractNo || '-'" />
        <u-cell title="发货单号"
                :value="detail.shippingNo || '-'" />
        <u-cell title="所属项目"
                :value="detail.projectName || '-'" />
        <u-cell title="客户名称"
                :value="detail.customerName || '-'" />
        <u-cell title="产品名称"
                :value="detail.productName || '-'" />
        <u-cell title="规格型号"
                :value="detail.specificationModel || '-'" />
        <u-cell title="发货数量"
                :value="detail.totalQuantity != null ? String(detail.totalQuantity) : '-'" />
      </u-cell-group>
      <u-cell-group title="发货信息"
                    class="detail-section">
        <u-cell title="发货类型"
                :value="detail.type || '-'" />
        <u-cell title="发货日期"
                :value="detail.shippingDate || '-'" />
        <u-cell title="发货车牌号"
                :value="detail.shippingCarNumber || '-'" />
        <u-cell title="快递公司"
                :value="detail.expressCompany || '-'" />
        <u-cell title="快递单号"
                :value="detail.expressNumber || '-'" />
        <u-cell title="出库批次"
                :value="detail.outboundBatches || '-'" />
      </u-cell-group>
      <u-cell-group title="单据与归档"
                    class="detail-section">
        <u-cell title="发货单据"
                :value="detail.shippingDocument || '-'" />
        <u-cell title="是否对账"
                :value="yesNo(detail.isReconciled)" />
        <u-cell v-if="Number(detail.isReconciled) === 1"
                title="对账单归档情况"
                :value="detail.statementArchivingStatus || '-'" />
        <u-cell title="是否结算"
                :value="yesNo(detail.isSettled)" />
        <u-cell v-if="Number(detail.isSettled) === 1"
                title="结算单归档情况"
                :value="detail.settlementArchivingStatus || '-'" />
        <u-cell title="是否已开票"
                :value="yesNo(detail.isInvoiced)" />
      </u-cell-group>
      <u-cell-group title="发货产品明细"
                    class="detail-section">
        <view v-if="products.length === 0"
              class="empty-block">
          <text>暂无产品明细</text>
        </view>
        <view v-for="(row, index) in products"
              :key="index"
              class="product-row">
          <view class="detail-row">
            <text class="detail-label">产品名称</text>
            <text class="detail-value">{{ row.productName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">规格型号</text>
            <text class="detail-value">{{ row.specificationModel || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">批次号</text>
            <text class="detail-value">{{ row.batchNo || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发货数量</text>
            <text class="detail-value">
              {{ row.deliveryQuantity === "" ? "-" : row.deliveryQuantity }}{{ row.unit ? ` ${row.unit}` : "" }}
            </text>
          </view>
        </view>
      </u-cell-group>
      <u-cell-group title="发货图片"
                    class="detail-section">
        <view v-if="files.length === 0"
              class="empty-block">
          <text>暂无图片</text>
        </view>
        <view v-else
              class="file-list">
          <view v-for="(file, index) in files"
                :key="index"
                class="file-item"
                @click="previewFile(file)">
            <up-icon name="photo"
                     size="18"
                     color="#2979ff"></up-icon>
            <text class="file-name">{{ fileLabel(file, index) }}</text>
            <up-icon name="download"
                     size="16"
                     color="#909399"></up-icon>
          </view>
        </view>
      </u-cell-group>
      <u-cell-group title="退货单"
                    class="detail-section">
        <view v-if="returnNos.length === 0"
              class="empty-block">
          <text>暂无退货单</text>
        </view>
        <view v-for="(no, index) in returnNos"
              :key="index"
              class="file-item">
          <up-icon name="arrow-return-left"
                   size="18"
                   color="#ff9900"></up-icon>
          <text class="file-name">{{ no }}</text>
        </view>
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { loadDetailData } from "@/utils/detailCache";
  import {
    resolveDeliveryFiles,
    resolveDeliveryProducts,
    resolveReturnNos,
  } from "@/utils/delivery";
  import { getDeliveryDetail } from "@/api/salesManagement/deliveryLedger";
  // è¿™ä¸¤ç§çŠ¶æ€å·²è¿›å…¥æ­£å¼æµç¨‹ï¼Œä¸å…è®¸å†æ”¹å‘è´§ä¿¡æ¯
  const LOCKED_STATUS = ["审核通过", "已发货"];
  const STATUS_TEXT = {
    0: "待审核",
    1: "审核中",
    2: "审核拒绝",
    3: "审核通过",
  };
  const loading = ref(true);
  const deliveryId = ref("");
  const detail = ref({});
  const products = computed(() => resolveDeliveryProducts(detail.value));
  const files = computed(() => resolveDeliveryFiles(detail.value));
  const returnNos = computed(() => resolveReturnNos(detail.value.returnNoList));
  const locked = computed(() => LOCKED_STATUS.includes(detail.value?.status));
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    if (locked.value) {
      uni.showToast({ title: "该状态的数据不可编辑发货信息", icon: "none" });
      return;
    }
    uni.setStorageSync("deliveryDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/sales/deliveryLedger/edit?id=${deliveryId.value}`,
    });
  };
  const statusText = status => {
    if (status === null || status === undefined || status === "") return "-";
    return STATUS_TEXT[status] || String(status);
  };
  const statusTagType = status => {
    if (status === "已发货" || status === 3 || status === "3") return "success";
    if (status === 1 || status === "1") return "warning";
    if (status === 2 || status === "2") return "error";
    return "info";
  };
  const yesNo = value => {
    if (value === null || value === undefined || value === "") return "-";
    return Number(value) === 1 ? "是" : "否";
  };
  const fileLabel = (file, index) =>
    file.name || file.originalFilename || `图片${index + 1}`;
  // request.ts æœªé€ä¼  responseType,blob ä¸‹è½½ä¸å¯ç”¨ï¼Œåªèƒ½ç”¨ downloadFile + openDocument
  const previewFile = file => {
    const url = file.url || file.previewURL;
    if (!url) return;
    uni.showLoading({ title: "打开中...", mask: true });
    uni.downloadFile({
      url,
      success: res => {
        if (res.statusCode !== 200) {
          uni.showToast({ title: "文件下载失败", icon: "none" });
          return;
        }
        uni.openDocument({
          filePath: res.tempFilePath,
          showMenu: true,
          fail: () => uni.showToast({ title: "暂不支持预览该文件", icon: "none" }),
        });
      },
      fail: () => uni.showToast({ title: "文件下载失败", icon: "none" }),
      complete: () => uni.hideLoading(),
    });
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      const data = await loadDetailData({
        cacheKey: "deliveryDetail",
        id: deliveryId.value,
        fetcher: getDeliveryDetail,
      });
      detail.value = data || {};
    } catch (error) {
      uni.showToast({ title: "获取发货详情失败", icon: "none" });
      detail.value = {};
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    deliveryId.value = options?.id || "";
    if (!deliveryId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .delivery-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #eef6ff 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(41, 121, 255, 0.08);
  }
  .hero-left {
    display: flex;
    flex-direction: column;
    gap: 6px;
    max-width: 70%;
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    word-break: break-all;
  }
  .hero-sub {
    font-size: 13px;
    color: #606266;
    word-break: break-all;
  }
  .lock-banner {
    margin-top: 12px;
    padding: 10px 14px;
    border-radius: 10px;
    background: #f4f4f5;
  }
  .lock-banner-text {
    font-size: 13px;
    color: #909399;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .empty-block {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .product-row {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f8;
  }
  .product-row:last-child {
    border-bottom: none;
  }
  .detail-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    padding: 4px 0;
  }
  .detail-label {
    font-size: 13px;
    color: #909399;
  }
  .detail-value {
    font-size: 13px;
    color: #333;
    max-width: 70%;
    text-align: right;
    word-break: break-all;
  }
  .file-list {
    padding: 6px 16px 12px;
  }
  .file-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 16px;
  }
  .file-name {
    flex: 1;
    font-size: 14px;
    color: #333;
    word-break: break-all;
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/deliveryLedger/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,385 @@
<template>
  <view class="account-detail">
    <PageHeader title="补充发货信息"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="发货信息"
                      class="form-section">
          <up-form-item label="发货类型"
                        prop="type"
                        required
                        border-bottom>
            <FormPicker v-model="form.type"
                        type="select"
                        title="选择发货类型"
                        :options="typeOptions"
                        placeholder="请选择发货类型"
                        @change="onTypeChange" />
          </up-form-item>
          <up-form-item label="发货日期"
                        prop="shippingDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.shippingDate"
                        type="date"
                        placeholder="请选择发货日期" />
          </up-form-item>
          <up-form-item v-if="form.type === '货车'"
                        label="发货车牌号"
                        prop="shippingCarNumber"
                        required
                        border-bottom>
            <up-input v-model="form.shippingCarNumber"
                      placeholder="请输入发货车牌号"
                      clearable />
          </up-form-item>
          <up-form-item v-if="form.type === '快递'"
                        label="快递公司"
                        prop="expressCompany"
                        required
                        border-bottom>
            <up-input v-model="form.expressCompany"
                      placeholder="请输入快递公司"
                      clearable />
          </up-form-item>
          <up-form-item v-if="form.type === '快递'"
                        label="快递单号"
                        prop="expressNumber">
            <up-input v-model="form.expressNumber"
                      placeholder="请输入快递单号"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="单据与归档"
                      class="form-section">
          <up-form-item label="发货单据"
                        prop="shippingDocument"
                        border-bottom>
            <up-input v-model="form.shippingDocument"
                      placeholder="请输入发货单据"
                      clearable />
          </up-form-item>
          <up-form-item label="是否对账"
                        prop="isReconciled"
                        border-bottom>
            <up-switch v-model="form.isReconciled"
                       :active-value="1"
                       :inactive-value="0" />
          </up-form-item>
          <up-form-item v-if="form.isReconciled === 1"
                        label="对账单归档"
                        prop="statementArchivingStatus"
                        border-bottom>
            <up-textarea v-model="form.statementArchivingStatus"
                         placeholder="请填写对账单归档情况"
                         auto-height
                         maxlength="200" />
          </up-form-item>
          <up-form-item label="是否结算"
                        prop="isSettled"
                        border-bottom>
            <up-switch v-model="form.isSettled"
                       :active-value="1"
                       :inactive-value="0" />
          </up-form-item>
          <up-form-item v-if="form.isSettled === 1"
                        label="结算单归档"
                        prop="settlementArchivingStatus"
                        border-bottom>
            <up-textarea v-model="form.settlementArchivingStatus"
                         placeholder="请填写结算单归档情况"
                         auto-height
                         maxlength="200" />
          </up-form-item>
          <up-form-item label="是否已开票"
                        prop="isInvoiced">
            <up-switch v-model="form.isInvoiced"
                       :active-value="1"
                       :inactive-value="0" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="发货图片"
                      class="form-section">
          <view class="upload-wrap">
            <CommonUpload v-model="form.deliveryFiles"
                          accept="image"
                          :max-count="6" />
          </view>
        </u-cell-group>
      </up-form>
      <u-cell-group title="发货产品明细"
                    class="form-section">
        <view v-if="products.length === 0"
              class="empty-block">
          <text>暂无产品明细</text>
        </view>
        <view v-for="(row, index) in products"
              :key="index"
              class="product-row">
          <view class="detail-row">
            <text class="detail-label">产品名称</text>
            <text class="detail-value">{{ row.productName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">规格型号</text>
            <text class="detail-value">{{ row.specificationModel || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">批次号</text>
            <text class="detail-value">{{ row.batchNo || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发货数量</text>
            <text class="detail-value">
              {{ row.deliveryQuantity === "" ? "-" : row.deliveryQuantity }}{{ row.unit ? ` ${row.unit}` : "" }}
            </text>
          </view>
        </view>
      </u-cell-group>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { loadDetailData } from "@/utils/detailCache";
  import {
    resolveDeliveryFiles,
    resolveDeliveryProducts,
  } from "@/utils/delivery";
  import {
    deductStock,
    getDeliveryDetail,
  } from "@/api/salesManagement/deliveryLedger";
  const formRef = ref();
  const loading = ref(false);
  const deliveryId = ref("");
  const products = ref([]);
  const typeOptions = [
    { name: "货车", value: "货车" },
    { name: "快递", value: "快递" },
  ];
  const form = ref({
    id: undefined,
    type: "",
    shippingDate: "",
    shippingCarNumber: "",
    expressCompany: "",
    expressNumber: "",
    shippingDocument: "",
    isReconciled: 0,
    statementArchivingStatus: "",
    isSettled: 0,
    settlementArchivingStatus: "",
    isInvoiced: 0,
    deliveryFiles: [],
  });
  // è½¦ç‰Œä¸Žå¿«é€’公司按发货类型二选一必填
  const rules = {
    type: [{ required: true, message: "请选择发货类型", trigger: "change" }],
    shippingDate: [
      { required: true, message: "请选择发货日期", trigger: "change" },
    ],
  };
  const goBack = () => uni.navigateBack();
  const onTypeChange = value => {
    if (value === "货车") {
      form.value.expressCompany = "";
      form.value.expressNumber = "";
    } else {
      form.value.shippingCarNumber = "";
    }
  };
  const loadDetail = async () => {
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "deliveryDetail",
        id: deliveryId.value,
        fetcher: getDeliveryDetail,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "未找到发货信息", icon: "none" });
      return;
    }
    form.value = {
      id: data.id,
      type: data.type || "",
      shippingDate: (data.shippingDate || "").slice(0, 10),
      shippingCarNumber: data.shippingCarNumber || "",
      expressCompany: data.expressCompany || "",
      expressNumber: data.expressNumber || "",
      shippingDocument: data.shippingDocument || "",
      isReconciled: data.isReconciled ?? 0,
      statementArchivingStatus: data.statementArchivingStatus || "",
      isSettled: data.isSettled ?? 0,
      settlementArchivingStatus: data.settlementArchivingStatus || "",
      isInvoiced: data.isInvoiced ?? 0,
      deliveryFiles: resolveDeliveryFiles(data),
    };
    products.value = resolveDeliveryProducts(data);
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    if (form.value.type === "货车" && !form.value.shippingCarNumber) {
      uni.showToast({ title: "请输入发货车牌号", icon: "none" });
      return;
    }
    if (form.value.type === "快递" && !form.value.expressCompany) {
      uni.showToast({ title: "请输入快递公司", icon: "none" });
      return;
    }
    // éœ€æ±‚六:发货台账要求补充单据与对账/结算/开票情况。
    // å‚考实现(管理端)只提交前 7 ä¸ªå­—段,这里按需求一并提交,
    // åŽç«¯è‹¥æœªæŽ¥æ”¶è¿™å‡ ä¸ªå­—段会被忽略,不影响发货主流程。
    const payload = {
      id: form.value.id,
      type: form.value.type,
      shippingDate: form.value.shippingDate,
      shippingCarNumber:
        form.value.type === "货车" ? form.value.shippingCarNumber : "",
      expressCompany:
        form.value.type === "快递" ? form.value.expressCompany : "",
      expressNumber: form.value.type === "快递" ? form.value.expressNumber : "",
      storageBlobDTOs: (form.value.deliveryFiles || [])
        .filter(file => file?.url)
        .map(file => ({ url: file.url, name: file.name || "" })),
      shippingDocument: form.value.shippingDocument,
      isReconciled: form.value.isReconciled,
      statementArchivingStatus:
        form.value.isReconciled === 1
          ? form.value.statementArchivingStatus
          : "",
      isSettled: form.value.isSettled,
      settlementArchivingStatus:
        form.value.isSettled === 1 ? form.value.settlementArchivingStatus : "",
      isInvoiced: form.value.isInvoiced,
    };
    loading.value = true;
    deductStock(payload)
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 1200);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      deliveryId.value = options.id;
      form.value.id = options.id;
      loadDetail();
    } else {
      form.value.shippingDate = formatDateToYMD(Date.now());
    }
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .upload-wrap {
    padding: 12px;
  }
  .empty-block {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .product-row {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f8;
  }
  .product-row:last-child {
    border-bottom: none;
  }
  .detail-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    padding: 4px 0;
  }
  .detail-label {
    font-size: 13px;
    color: #909399;
  }
  .detail-value {
    font-size: 13px;
    color: #333;
    max-width: 70%;
    text-align: right;
    word-break: break-all;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/deliveryLedger/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,336 @@
<template>
  <view class="sales-account">
    <PageHeader title="发货台账"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.salesContractNo"
                    placeholder="请输入销售合同号搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="deliveryList.length > 0"
          class="ledger-list">
      <view v-for="item in deliveryList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="car"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.shippingNo || item.salesContractNo || "-" }}</text>
          </view>
          <up-tag :text="statusText(item.status)"
                  :type="statusTagType(item.status)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">销售合同号</text>
            <text class="detail-value">{{ item.salesContractNo || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">所属项目</text>
            <text class="detail-value">{{ item.projectName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">客户名称</text>
            <text class="detail-value">{{ item.customerName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">产品名称</text>
            <text class="detail-value">{{ item.productName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">规格型号</text>
            <text class="detail-value">{{ item.specificationModel || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发货日期</text>
            <text class="detail-value">{{ item.shippingDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发货数量</text>
            <text class="detail-value">{{ item.totalQuantity ?? "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发货车牌号</text>
            <text class="detail-value">{{ item.shippingCarNumber || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">快递公司</text>
            <text class="detail-value">{{ item.expressCompany || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">快递单号</text>
            <text class="detail-value">{{ item.expressNumber || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">出库批次</text>
            <text class="detail-value">{{ item.outboundBatches || "-" }}</text>
          </view>
        </view>
        <view v-if="returnNos(item).length > 0"
              class="return-entry"
              @click="showReturnNos(item)">
          <up-icon name="arrow-return-left"
                   size="15"
                   color="#ff9900"></up-icon>
          <text class="return-entry-text">查看 {{ returnNos(item).length }} ä¸ªé€€è´§å•</text>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     :disabled="isLocked(item)"
                     @click="goEdit(item)">补充发货信息</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     :disabled="isLocked(item)"
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无发货台账数据</text>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="发货台账筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    deliveryLedgerListPage,
    delDeliveryLedger,
  } from "@/api/salesManagement/deliveryLedger";
  import { unwrapList } from "@/utils/list";
  import { loadProjectOptions } from "@/utils/projectOptions";
  // è¿™ä¸¤ç§çŠ¶æ€å·²è¿›å…¥æ­£å¼æµç¨‹ï¼Œä¸å…è®¸å†æ”¹å‘è´§ä¿¡æ¯æˆ–åˆ é™¤
  const LOCKED_STATUS = ["审核通过", "已发货"];
  const STATUS_TEXT = {
    0: "待审核",
    1: "审核中",
    2: "审核拒绝",
    3: "审核通过",
  };
  const deliveryList = ref([]);
  const showFilter = ref(false);
  const projectOptions = ref([]);
  const query = reactive({
    salesContractNo: "",
    shippingCarNumber: "",
    expressNumber: "",
    projectId: "",
    shippingDateRange: [],
  });
  const filterFields = computed(() => [
    {
      prop: "salesContractNo",
      label: "销售合同号",
      type: "input",
      placeholder: "请输入销售合同号",
    },
    {
      prop: "shippingCarNumber",
      label: "发货车牌号",
      type: "input",
      placeholder: "请输入发货车牌号",
    },
    {
      prop: "expressNumber",
      label: "快递单号",
      type: "input",
      placeholder: "请输入快递单号",
    },
    { prop: "projectId", label: "所属项目", type: "select", options: projectOptions.value },
    { prop: "shippingDateRange", label: "发货日期", type: "daterange" },
  ]);
  const goBack = () => uni.navigateBack();
  const statusText = status => {
    if (status === null || status === undefined || status === "") return "-";
    return STATUS_TEXT[status] || String(status);
  };
  const statusTagType = status => {
    if (status === "已发货" || status === 3 || status === "3") return "success";
    if (status === 1 || status === "1") return "warning";
    if (status === 2 || status === "2") return "error";
    return "info";
  };
  const isLocked = item => LOCKED_STATUS.includes(item?.status);
  const returnNos = item =>
    Array.isArray(item?.returnNoList) ? item.returnNoList : [];
  const goDetail = item => {
    uni.setStorageSync("deliveryDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/deliveryLedger/detail?id=${item.id}`,
    });
  };
  const goEdit = item => {
    if (isLocked(item)) {
      uni.showToast({ title: "该状态的数据不可编辑发货信息", icon: "none" });
      return;
    }
    uni.setStorageSync("deliveryDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/deliveryLedger/edit?id=${item.id}`,
    });
  };
  // éœ€æ±‚å…­.4:退货统计,弹窗列举退货单号
  const showReturnNos = item => {
    const list = returnNos(item);
    if (list.length === 0) return;
    uni.showModal({
      title: "退货单号",
      content: list.join("\n"),
      showCancel: false,
      confirmText: "知道了",
    });
  };
  const getList = () => {
    const [beginShippingDate, endShippingDate] = Array.isArray(
      query.shippingDateRange
    )
      ? query.shippingDateRange
      : [];
    deliveryLedgerListPage({
      current: -1,
      size: -1,
      salesContractNo: query.salesContractNo || undefined,
      shippingCarNumber: query.shippingCarNumber || undefined,
      expressNumber: query.expressNumber || undefined,
      projectId: query.projectId || undefined,
      beginShippingDate: beginShippingDate || undefined,
      endShippingDate: endShippingDate || undefined,
    })
      .then(res => {
        deliveryList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    if (isLocked(item)) {
      uni.showToast({ title: "该状态的数据不可删除", icon: "none" });
      return;
    }
    uni.showModal({
      title: "删除确认",
      content: "确认删除该发货台账吗?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delDeliveryLedger(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  let projectsLoaded = false;
  const loadProjects = () => {
    if (projectsLoaded) return;
    projectsLoaded = true;
    loadProjectOptions()
      .then(options => {
        projectOptions.value = options;
      })
      .catch(() => {
        projectsLoaded = false;
      });
  };
  onShow(() => {
    getList();
    loadProjects();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
  .return-entry {
    display: flex;
    align-items: center;
    gap: 6px;
    margin: 0 12px 10px;
    padding: 8px 10px;
    border-radius: 8px;
    background: #fffaf0;
  }
  .return-entry-text {
    font-size: 13px;
    color: #ff9900;
  }
</style>
src/pages/sales/lawsuitManagement/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,262 @@
<template>
  <view class="lawsuit-detail">
    <PageHeader title="诉讼详情"
                @back="goBack">
      <template #right>
        <text class="header-action"
              @click="goEdit">编辑</text>
      </template>
    </PageHeader>
    <view v-if="!loading"
          class="detail-card">
      <view class="hero">
        <text class="hero-title">{{ detail.caseNo || "-" }}</text>
        <up-tag :text="detail.lawsuitStatus || '未填状态'"
                :type="statusTagType(detail.lawsuitStatus)"
                size="mini"
                shape="circle" />
      </view>
      <u-cell-group title="案件信息"
                    class="detail-section">
        <u-cell title="案件编号"
                :value="detail.caseNo || '-'" />
        <u-cell title="诉讼状态"
                :value="detail.lawsuitStatus || '-'" />
        <u-cell title="诉讼标的金额"
                :value="formatAmount(detail.claimAmount)" />
        <u-cell title="立案时间"
                :value="detail.filingDate || '-'" />
        <u-cell title="开庭时间"
                :value="detail.hearingDate || '-'" />
        <u-cell title="承办律师"
                :value="detail.lawyer || '-'" />
      </u-cell-group>
      <u-cell-group title="关联信息"
                    class="detail-section">
        <u-cell title="所属项目"
                :value="detail.projectName || '-'" />
        <u-cell title="关联销售合同"
                :value="detail.contractNo || '-'" />
      </u-cell-group>
      <u-cell-group title="进展与结果"
                    class="detail-section">
        <u-cell title="判决结果摘要"
                :value="detail.judgmentSummary || '-'" />
        <u-cell title="进展备注"
                :value="detail.progressRemark || '-'" />
      </u-cell-group>
      <u-cell-group title="附件"
                    class="detail-section">
        <view v-if="attachments.length === 0"
              class="empty-file">
          <text>暂无附件</text>
        </view>
        <view v-else
              class="file-list">
          <view v-for="(file, index) in attachments"
                :key="index"
                class="file-item"
                @click="previewFile(file, index)">
            <up-icon name="file-text"
                     size="18"
                     color="#2979ff"></up-icon>
            <text class="file-name">{{ fileLabel(file, index) }}</text>
            <up-icon name="download"
                     size="16"
                     color="#909399"></up-icon>
          </view>
        </view>
      </u-cell-group>
    </view>
    <view v-else
          class="loading-tip">
      <text>加载中...</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { toFileList } from "@/utils/file";
  import { loadDetailData } from "@/utils/detailCache";
  import { getLawsuitDetail } from "@/api/salesManagement/lawsuitManagement";
  const loading = ref(true);
  const lawsuitId = ref("");
  const detail = ref({});
  const attachments = computed(() =>
    toFileList(
      detail.value.storageBlobVOs ||
        detail.value.attachmentList ||
        detail.value.fileList
    )
  );
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    uni.setStorageSync("lawsuitDetail", detail.value || {});
    uni.navigateTo({
      url: `/pages/sales/lawsuitManagement/edit?id=${lawsuitId.value}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const statusTagType = status => {
    if (status === "已判决" || status === "已和解") return "success";
    if (status === "已撤诉") return "info";
    if (status) return "warning";
    return "info";
  };
  const fileLabel = (file, index) =>
    file.name || file.originalFilename || `附件${index + 1}`;
  // request.ts æœªé€ä¼  responseType,blob ä¸‹è½½ä¸å¯ç”¨ï¼Œåªèƒ½ç”¨ downloadFile + openDocument
  const previewFile = file => {
    const url = file.url || file.previewURL;
    if (!url) return;
    uni.showLoading({ title: "打开中...", mask: true });
    uni.downloadFile({
      url,
      success: res => {
        if (res.statusCode !== 200) {
          uni.showToast({ title: "文件下载失败", icon: "none" });
          return;
        }
        uni.openDocument({
          filePath: res.tempFilePath,
          showMenu: true,
          fail: () => uni.showToast({ title: "暂不支持预览该文件", icon: "none" }),
        });
      },
      fail: () => uni.showToast({ title: "文件下载失败", icon: "none" }),
      complete: () => uni.hideLoading(),
    });
  };
  const loadDetail = async () => {
    loading.value = true;
    try {
      const data = await loadDetailData({
        cacheKey: "lawsuitDetail",
        id: lawsuitId.value,
        fetcher: getLawsuitDetail,
      });
      detail.value = data || {};
    } catch (error) {
      uni.showToast({ title: "获取诉讼详情失败", icon: "none" });
      detail.value = {};
    } finally {
      loading.value = false;
    }
  };
  onLoad(options => {
    lawsuitId.value = options?.id || "";
    if (!lawsuitId.value) {
      loading.value = false;
      return;
    }
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .lawsuit-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 24px;
  }
  .header-action {
    font-size: 14px;
    color: #2979ff;
  }
  .detail-card {
    padding: 12px;
  }
  .hero {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 18px;
    border-radius: 16px;
    background: linear-gradient(135deg, #fff7ea 0%, #ffffff 100%);
    box-shadow: 0 6px 18px rgba(230, 162, 60, 0.1);
  }
  .hero-title {
    font-size: 18px;
    font-weight: 600;
    color: #1f2d3d;
    max-width: 70%;
    word-break: break-all;
  }
  .detail-section {
    margin-top: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .empty-file {
    padding: 16px;
    font-size: 14px;
    color: #909399;
  }
  .file-list {
    padding: 6px 16px 12px;
  }
  .file-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 0;
    border-bottom: 1px solid #f2f4f8;
  }
  .file-item:last-child {
    border-bottom: none;
  }
  .file-name {
    flex: 1;
    font-size: 14px;
    color: #333;
    word-break: break-all;
  }
  .loading-tip {
    padding: 80px 0;
    text-align: center;
    font-size: 14px;
    color: #909399;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/lawsuitManagement/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,344 @@
<template>
  <view class="account-detail">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="form-container">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="案件信息"
                      class="form-section">
          <up-form-item label="案件编号"
                        prop="caseNo"
                        required
                        border-bottom>
            <up-input v-model="form.caseNo"
                      placeholder="请输入案件编号"
                      clearable />
          </up-form-item>
          <up-form-item label="诉讼状态"
                        prop="lawsuitStatus"
                        required
                        border-bottom>
            <FormPicker v-model="form.lawsuitStatus"
                        type="select"
                        title="选择诉讼状态"
                        :options="lawsuitStatusOptions"
                        placeholder="请选择诉讼状态" />
          </up-form-item>
          <up-form-item label="诉讼标的金额"
                        prop="claimAmount"
                        border-bottom>
            <up-input v-model="form.claimAmount"
                      type="number"
                      placeholder="请输入诉讼标的金额"
                      clearable />
          </up-form-item>
          <up-form-item label="立案时间"
                        prop="filingDate"
                        required
                        border-bottom>
            <FormPicker v-model="form.filingDate"
                        type="date"
                        placeholder="请选择立案时间" />
          </up-form-item>
          <up-form-item label="开庭时间"
                        prop="hearingDate"
                        border-bottom>
            <FormPicker v-model="form.hearingDate"
                        type="date"
                        placeholder="请选择开庭时间" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="关联信息"
                      class="form-section">
          <up-form-item label="所属项目"
                        prop="projectId"
                        required
                        border-bottom>
            <FormPicker v-model="form.projectId"
                        :display-text="form.projectName"
                        type="select"
                        title="选择所属项目"
                        :options="projectOptions"
                        placeholder="请选择所属项目"
                        @change="onProjectChange" />
          </up-form-item>
          <up-form-item label="关联销售合同"
                        prop="contractId"
                        required
                        border-bottom>
            <FormPicker v-model="form.contractId"
                        :display-text="form.contractNo"
                        type="select"
                        title="选择关联销售合同"
                        :options="contractOptions"
                        placeholder="请选择关联销售合同"
                        @change="onContractChange" />
          </up-form-item>
          <up-form-item label="承办律师"
                        prop="lawyer"
                        border-bottom>
            <up-input v-model="form.lawyer"
                      placeholder="请输入承办律师/法务负责人"
                      clearable />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="进展与结果"
                      class="form-section">
          <up-form-item label="判决结果摘要"
                        prop="judgmentSummary"
                        border-bottom>
            <up-textarea v-model="form.judgmentSummary"
                         placeholder="请输入判决结果摘要"
                         auto-height
                         maxlength="500" />
          </up-form-item>
          <up-form-item label="进展备注"
                        prop="progressRemark">
            <up-textarea v-model="form.progressRemark"
                         placeholder="请输入诉讼进展备注"
                         auto-height
                         maxlength="500" />
          </up-form-item>
        </u-cell-group>
        <u-cell-group title="附件"
                      class="form-section">
          <view class="upload-wrap">
            <CommonUpload v-model="form.attachmentFiles"
                          accept="all"
                          :max-count="5" />
          </view>
        </u-cell-group>
      </up-form>
    </view>
    <FooterButtons :loading="loading"
                   confirmText="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import { toFileList } from "@/utils/file";
  import { loadDetailData } from "@/utils/detailCache";
  import { loadProjectOptions, pickProjectName } from "@/utils/projectOptions";
  import {
    addOrUpdateLawsuit,
    getLawsuitDetail,
  } from "@/api/salesManagement/lawsuitManagement";
  import { contractListNoPage } from "@/api/salesManagement/contractManagement";
  const formRef = ref();
  const loading = ref(false);
  const lawsuitId = ref("");
  const projectOptions = ref([]);
  const contractOptions = ref([]);
  const lawsuitStatusOptions = [
    { name: "立案中", value: "立案中" },
    { name: "审理中", value: "审理中" },
    { name: "已判决", value: "已判决" },
    { name: "已撤诉", value: "已撤诉" },
    { name: "已和解", value: "已和解" },
  ];
  const form = ref({
    id: undefined,
    caseNo: "",
    lawsuitStatus: "",
    claimAmount: "",
    filingDate: "",
    hearingDate: "",
    lawyer: "",
    projectId: "",
    projectName: "",
    contractId: "",
    contractNo: "",
    judgmentSummary: "",
    progressRemark: "",
    attachmentFiles: [],
  });
  const rules = {
    caseNo: [{ required: true, message: "请输入案件编号", trigger: "blur" }],
    lawsuitStatus: [
      { required: true, message: "请选择诉讼状态", trigger: "change" },
    ],
    filingDate: [{ required: true, message: "请选择立案时间", trigger: "change" }],
    projectId: [{ required: true, message: "请选择所属项目", trigger: "change" }],
    contractId: [
      { required: true, message: "请选择关联销售合同", trigger: "change" },
    ],
  };
  const pageTitle = computed(() => (lawsuitId.value ? "编辑诉讼" : "新增诉讼"));
  const goBack = () => uni.navigateBack();
  const numOrNull = value =>
    value === "" || value === null || value === undefined
      ? null
      : Number.isNaN(Number(value))
        ? null
        : Number(value);
  const onProjectChange = (value, item) => {
    form.value.projectName = pickProjectName(item);
  };
  const onContractChange = (value, item) => {
    form.value.contractNo = item?.contractNo || "";
  };
  const loadOptions = async () => {
    try {
      projectOptions.value = await loadProjectOptions();
    } catch (error) {
      projectOptions.value = [];
    }
    try {
      const res = await contractListNoPage();
      const records = res?.data?.records || res?.records || res?.data || [];
      contractOptions.value = (Array.isArray(records) ? records : []).map(
        item => ({
          name: item.signingUnit
            ? `${item.contractNo}(${item.signingUnit})`
            : item.contractNo,
          value: item.id,
          raw: item,
        })
      );
    } catch (error) {
      contractOptions.value = [];
    }
  };
  const loadDetail = async () => {
    let data = null;
    try {
      data = await loadDetailData({
        cacheKey: "lawsuitDetail",
        id: lawsuitId.value,
        fetcher: getLawsuitDetail,
      });
    } catch (error) {
      data = null;
    }
    if (!data) {
      uni.showToast({ title: "未找到诉讼信息", icon: "none" });
      return;
    }
    form.value = {
      id: data.id,
      caseNo: data.caseNo || "",
      lawsuitStatus: data.lawsuitStatus || "",
      claimAmount: data.claimAmount ?? "",
      filingDate: (data.filingDate || "").slice(0, 10),
      hearingDate: (data.hearingDate || "").slice(0, 10),
      lawyer: data.lawyer || "",
      projectId: data.projectId ?? "",
      projectName: data.projectName || "",
      contractId: data.contractId ?? "",
      contractNo: data.contractNo || "",
      judgmentSummary: data.judgmentSummary || "",
      progressRemark: data.progressRemark || "",
      attachmentFiles: toFileList(
        data.storageBlobVOs || data.attachmentList || data.fileList
      ),
    };
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid) return;
    const payload = {
      id: form.value.id,
      caseNo: form.value.caseNo,
      lawsuitStatus: form.value.lawsuitStatus,
      claimAmount: numOrNull(form.value.claimAmount),
      filingDate: form.value.filingDate,
      hearingDate: form.value.hearingDate || null,
      lawyer: form.value.lawyer,
      projectId: form.value.projectId,
      projectName: form.value.projectName,
      contractId: form.value.contractId,
      contractNo: form.value.contractNo,
      judgmentSummary: form.value.judgmentSummary,
      progressRemark: form.value.progressRemark,
      storageBlobDTOs: (form.value.attachmentFiles || [])
        .filter(file => file?.url)
        .map(file => ({ url: file.url, name: file.name || "" })),
    };
    loading.value = true;
    addOrUpdateLawsuit(payload)
      .then(() => {
        uni.showToast({ title: "保存成功", icon: "success" });
        setTimeout(() => uni.navigateBack(), 1000);
      })
      .catch(() => {
        uni.showToast({ title: "保存失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    loadOptions();
    if (options?.id) {
      lawsuitId.value = options.id;
      form.value.id = options.id;
      loadDetail();
    }
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .account-detail {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 100px;
  }
  .form-container {
    padding: 12px 12px 0;
  }
  .form-section {
    margin-bottom: 12px;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 10px rgba(15, 35, 95, 0.05);
  }
  .upload-wrap {
    padding: 12px;
  }
  :deep(.u-cell-group__title) {
    padding: 14px 18px 10px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: #22324d !important;
    background: #f8fbff !important;
  }
</style>
src/pages/sales/lawsuitManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,264 @@
<template>
  <view class="sales-account">
    <PageHeader title="诉讼管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="query.caseNo"
                    placeholder="请输入案件编号搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="lawsuitList.length > 0"
          class="ledger-list">
      <view v-for="item in lawsuitList"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="file-text"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.caseNo || "-" }}</text>
          </view>
          <up-tag :text="item.lawsuitStatus || '未填状态'"
                  :type="statusTagType(item.lawsuitStatus)"
                  size="mini"
                  shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">所属项目</text>
            <text class="detail-value">{{ item.projectName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">关联合同</text>
            <text class="detail-value">{{ item.contractNo || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">诉讼标的</text>
            <text class="detail-value">{{ formatAmount(item.claimAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">立案时间</text>
            <text class="detail-value">{{ item.filingDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">开庭时间</text>
            <text class="detail-value">{{ item.hearingDate || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">承办律师</text>
            <text class="detail-value">{{ item.lawyer || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">判决结果</text>
            <text class="detail-value">{{ item.judgmentSummary || "-" }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goEdit(item)">编辑</up-button>
          <up-button class="action-btn"
                     size="small"
                     @click="goDetail(item)">详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     plain
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无诉讼数据</text>
    </view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="诉讼筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    lawsuitListPage,
    delLawsuit,
  } from "@/api/salesManagement/lawsuitManagement";
  import { unwrapList } from "@/utils/list";
  const LAWSUIT_STATUS_OPTIONS = [
    { name: "立案中", value: "立案中" },
    { name: "审理中", value: "审理中" },
    { name: "已判决", value: "已判决" },
    { name: "已撤诉", value: "已撤诉" },
    { name: "已和解", value: "已和解" },
  ];
  const lawsuitList = ref([]);
  const showFilter = ref(false);
  const query = reactive({
    caseNo: "",
    projectName: "",
    contractNo: "",
    lawsuitStatus: "",
    filingDateRange: [],
  });
  const filterFields = [
    {
      prop: "caseNo",
      label: "案件编号",
      type: "input",
      placeholder: "请输入案件编号",
    },
    {
      prop: "projectName",
      label: "所属项目",
      type: "input",
      placeholder: "请输入项目名称",
    },
    {
      prop: "contractNo",
      label: "合同编号",
      type: "input",
      placeholder: "请输入合同编号",
    },
    {
      prop: "lawsuitStatus",
      label: "诉讼状态",
      type: "select",
      options: LAWSUIT_STATUS_OPTIONS,
    },
    { prop: "filingDateRange", label: "立案时间", type: "daterange" },
  ];
  const goBack = () => uni.navigateBack();
  const goAdd = () => {
    uni.removeStorageSync("lawsuitDetail");
    uni.navigateTo({ url: "/pages/sales/lawsuitManagement/edit" });
  };
  const goEdit = item => {
    uni.setStorageSync("lawsuitDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/lawsuitManagement/edit?id=${item.id}`,
    });
  };
  const goDetail = item => {
    uni.setStorageSync("lawsuitDetail", item || {});
    uni.navigateTo({
      url: `/pages/sales/lawsuitManagement/detail?id=${item.id}`,
    });
  };
  const formatAmount = amount =>
    amount === null || amount === undefined || amount === ""
      ? "-"
      : `Â¥${Number(amount).toFixed(2)}`;
  const statusTagType = status => {
    if (status === "已判决" || status === "已和解") return "success";
    if (status === "已撤诉") return "info";
    if (status) return "warning";
    return "info";
  };
  const getList = () => {
    const [filingDateStart, filingDateEnd] = Array.isArray(query.filingDateRange)
      ? query.filingDateRange
      : [];
    lawsuitListPage({
      current: -1,
      size: -1,
      caseNo: query.caseNo || undefined,
      projectName: query.projectName || undefined,
      contractNo: query.contractNo || undefined,
      lawsuitStatus: query.lawsuitStatus || undefined,
      filingDateStart: filingDateStart || undefined,
      filingDateEnd: filingDateEnd || undefined,
    })
      .then(res => {
        lawsuitList.value = unwrapList(res);
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  const handleDelete = item => {
    if (!item?.id) return;
    uni.showModal({
      title: "删除确认",
      content: "确认删除该诉讼记录吗?",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "处理中...", mask: true });
        delLawsuit(item.id)
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .detail-value {
    max-width: 70%;
    word-break: break-all;
  }
</style>
src/pages/sales/receiptPaymentLedger/detail.vue
@@ -1,32 +1,97 @@
<template>
  <view class="receipt-payment-detail">
    <!-- ä½¿ç”¨é€šç”¨é¡µé¢å¤´éƒ¨ç»„ä»¶ -->
    <PageHeader title="客户往来详情"
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <!-- ç»Ÿè®¡ä¿¡æ¯ -->
    <view class="summary-info"
          v-if="tableData.length > 0">
    <!-- ç¬¬äºŒçº§ï¼šé¡¹ç›®ä¸‹çš„客户往来 -->
    <template v-if="level === 'customer'">
      <!-- é¡¹ç›®åº”收摘要(需求七:应收账款管理重点信息摘要) -->
      <view class="summary-info">
      <view class="summary-item">
        <text class="summary-label">总记录数</text>
        <text class="summary-value">{{ tableData.length }}</text>
          <text class="summary-label">客户数</text>
          <text class="summary-value">{{ customerTotal }}</text>
      </view>
      <view class="summary-item">
        <text class="summary-label">开票总金额</text>
        <text class="summary-value">{{ formatAmount(invoiceTotal) }}</text>
          <text class="summary-label">签约总额</text>
          <text class="summary-value">{{ fmtMoney(sumOf("contractAmounts")) }}</text>
      </view>
      <view class="summary-item">
        <text class="summary-label">回款总金额</text>
        <text class="summary-value highlight">{{ formatAmount(receiptTotal) }}</text>
          <text class="summary-label">已回款</text>
          <text class="summary-value highlight">{{ fmtMoney(sumOf("receiptPaymentAmount")) }}</text>
      </view>
      <view class="summary-item">
        <text class="summary-label">应收总金额</text>
        <text class="summary-value danger">{{ formatAmount(unReceiptTotal) }}</text>
          <text class="summary-label">应收总额</text>
          <text class="summary-value danger">{{ fmtMoney(sumOf("receiptableAmount")) }}</text>
        </view>
        <view class="summary-item">
          <text class="summary-label">超期合同</text>
          <text class="summary-value danger">{{ overdueCount }} ä»½</text>
      </view>
    </view>
    <!-- å›žæ¬¾è®°å½•明细列表 -->
    <view class="detail-list"
          v-if="tableData.length > 0">
      <view v-for="(item, index) in tableData"
            v-if="customerList.length > 0">
        <view v-for="item in customerList"
              :key="item.customerId"
              class="detail-item"
              @click="goContracts(item)">
          <view class="item-header">
            <view class="item-left">
              <view class="record-icon">
                <up-icon name="account"
                         size="16"
                         color="#ffffff" />
              </view>
              <text class="item-index">{{ item.customerName || "未命名客户" }}</text>
            </view>
            <up-tag :text="item.overdueCount > 0 ? `${item.overdueCount} ä»½è¶…期` : '正常履约'"
                    :type="item.overdueCount > 0 ? 'error' : 'success'"
                    size="mini"
                    shape="circle" />
          </view>
          <up-divider></up-divider>
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">签约总额</text>
              <text class="detail-value">{{ fmtMoney(item.contractAmounts) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">已回款</text>
              <text class="detail-value highlight">{{ fmtMoney(item.receiptPaymentAmount) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">应收金额</text>
              <text class="detail-value danger">{{ fmtMoney(item.receiptableAmount) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">退货金额</text>
              <text class="detail-value">{{ fmtMoney(item.returnAmount) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">回款进度</text>
              <text class="detail-value"
                    :class="{ danger: item.overdueCount > 0 }">
                {{ calcRate(item.receiptPaymentAmount, item.contractAmounts) }}%
              </text>
            </view>
            <view class="detail-row">
              <text class="detail-label">合同数</text>
              <text class="detail-value">{{ item.contractCount || 0 }} ä»½</text>
            </view>
          </view>
        </view>
      </view>
      <view v-else
            class="no-data">
        <text>该项目下暂无客户往来数据</text>
      </view>
    </template>
    <!-- ç¬¬ä¸‰çº§ï¼šå®¢æˆ·ä¸‹çš„合同明细 -->
    <template v-else>
      <view class="detail-list"
            v-if="contractList.length > 0">
        <view v-for="(item, index) in contractList"
            :key="index"
            class="detail-item">
        <view class="item-header">
@@ -34,143 +99,170 @@
            <view class="record-icon">
              <up-icon name="file-text"
                       size="16"
                       color="#ffffff"></up-icon>
                         color="#ffffff" />
            </view>
            <text class="item-index">{{ index + 1 }}</text>
              <text class="item-index">{{ item.contractName || item.salesContractNo || "未命名合同" }}</text>
          </view>
          <view class="item-date">{{ item.happenTime }}</view>
            <up-tag :text="contractStatusText(item)"
                    :type="contractStatusType(item)"
                    size="mini"
                    shape="circle" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">开票金额(元)</text>
            <text class="detail-value">{{ formatAmount(item.invoiceTotal) }}</text>
              <text class="detail-label">合同编号</text>
              <text class="detail-value">{{ item.salesContractNo || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">回款金额(元)</text>
            <text class="detail-value highlight">{{ formatAmount(item.receiptPaymentAmount) }}</text>
              <text class="detail-label">合同总额</text>
              <text class="detail-value">{{ fmtMoney(item.contractAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">应收金额(元)</text>
            <text class="detail-value danger">{{ formatAmount(item.unReceiptPaymentAmount) }}</text>
              <text class="detail-label">已回款</text>
              <text class="detail-value"
                    :class="{ danger: item.isPaymentTimeout }">
                {{ fmtMoney(item.receiptPaymentAmount) }}
                ({{ calcRate(item.receiptPaymentAmount, item.contractAmount) }}%)
              </text>
          </view>
          <view class="detail-row">
            <text class="detail-label">发生日期</text>
            <text class="detail-value">{{ item.receiptPaymentDate }}</text>
              <text class="detail-label">应收金额</text>
              <text class="detail-value danger">{{ fmtMoney(item.receiptableAmount) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">退货金额</text>
              <text class="detail-value">{{ fmtMoney(item.returnAmount) }}</text>
          </view>
        </view>
      </view>
    </view>
    <view v-else
          class="no-data">
      <text>暂无回款记录</text>
        <text>该客户下暂无合同明细</text>
    </view>
    </template>
  </view>
</template>
<script setup>
  import { ref, computed, onMounted } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import { customerInteractions } from "@/api/salesManagement/receiptPayment.js";
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import {
    customewTransactions,
    customewTransactionsDetails,
  } from "@/api/salesManagement/indicatorStats";
  import { unwrapList } from "@/utils/list";
  // å®¢æˆ·ä¿¡æ¯
  // level: customer = é¡¹ç›®ä¸‹çš„客户汇总;contract = å®¢æˆ·ä¸‹çš„合同明细
  const level = ref("customer");
  const projectId = ref("");
  const projectName = ref("");
  const customerId = ref("");
  const customerName = ref("");
  // è¡¨æ ¼æ•°æ®
  const tableData = ref([]);
  const customerList = ref([]);
  const contractList = ref([]);
  const customerTotal = ref(0);
  const invoiceTotal = computed(() => {
    return tableData.value.reduce((sum, item) => {
      return sum + (parseFloat(item.invoiceTotal) || 0);
    }, 0);
  const pageTitle = computed(() => {
    if (level.value === "customer") return projectName.value || "客户往来";
    return customerName.value || "合同明细";
  });
  const receiptTotal = computed(() => {
    return tableData.value.reduce((sum, item) => {
      return sum + (parseFloat(item.receiptPaymentAmount) || 0);
    }, 0);
  });
  const sumOf = field =>
    customerList.value.reduce((sum, item) => sum + (Number(item[field]) || 0), 0);
  const unReceiptTotal = computed(() => {
    return tableData.value.reduce((sum, item) => {
      return sum + (parseFloat(item.unReceiptPaymentAmount) || 0);
    }, 0);
  });
  const overdueCount = computed(() =>
    customerList.value.reduce((sum, item) => sum + (Number(item.overdueCount) || 0), 0)
  );
  // è¿”回上一页
  const goBack = () => {
    uni.removeStorageSync("customerId");
    uni.navigateBack();
  const loadCustomers = () => {
    if (!projectId.value) return;
    uni.showLoading({ title: "加载中...", mask: true });
    customewTransactions({ projectId: projectId.value, current: -1, size: -1 })
      .then(res => {
        customerList.value = unwrapList(res);
        customerTotal.value = Number(res?.data?.total ?? customerList.value.length);
        uni.hideLoading();
      })
      .catch(() => {
        uni.hideLoading();
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  // èŽ·å–é¡µé¢å‚æ•°
  const getPageParams = () => {
    // ä»Žæœ¬åœ°å­˜å‚¨èŽ·å–å®¢æˆ·ID
    const storedCustomerId = uni.getStorageSync("customerId");
    if (storedCustomerId) {
      customerId.value = storedCustomerId;
    }
  };
  // æŸ¥è¯¢åˆ—表
  const getList = () => {
    if (!customerId.value) {
      uni.showToast({
        title: "客户信息缺失",
        icon: "error",
      });
      return;
    }
    showLoadingToast("加载中...");
    const param = {
  const loadContracts = () => {
    if (!projectId.value || !customerId.value) return;
    uni.showLoading({ title: "加载中...", mask: true });
    customewTransactionsDetails({
      projectId: projectId.value,
      customerId: customerId.value,
      current: -1,
      size: -1,
    };
    customerInteractions(param)
    })
      .then(res => {
        tableData.value = res.data;
        closeToast();
        contractList.value = unwrapList(res);
        uni.hideLoading();
      })
      .catch(() => {
        closeToast();
        uni.showToast({
          title: "查询失败",
          icon: "error",
        });
      });
  };
  // æ ¼å¼åŒ–金额
  const formatAmount = amount => {
    return amount ? parseFloat(amount).toFixed(2) : "0.00";
  };
  // æ˜¾ç¤ºåŠ è½½æç¤º
  const showLoadingToast = message => {
    uni.showLoading({
      title: message,
      mask: true,
    });
  };
  // å…³é—­æç¤º
  const closeToast = () => {
    uni.hideLoading();
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  onShow(() => {
    // é¡µé¢æ˜¾ç¤ºæ—¶èŽ·å–å‚æ•°å¹¶åˆ·æ–°åˆ—è¡¨
    getPageParams();
    getList();
  });
  const goContracts = item => {
    customerId.value = item.customerId;
    customerName.value = item.customerName || "";
    level.value = "contract";
    loadContracts();
  };
  onMounted(() => {
    // é¡µé¢åŠ è½½æ—¶èŽ·å–å‚æ•°å¹¶åˆ·æ–°åˆ—è¡¨
    getPageParams();
    getList();
  // è¿”回:第三级退回第二级,第二级才真正退出页面
  const goBack = () => {
    if (level.value === "contract") {
      level.value = "customer";
      contractList.value = [];
      return;
    }
    uni.navigateBack();
  };
  const contractStatusText = item => {
    if (item.isPaymentTimeout) return "超期未回";
    const amount = Number(item.contractAmount) || 0;
    if (amount > 0 && (Number(item.receiptPaymentAmount) || 0) >= amount) return "已结清";
    return "履行中";
  };
  const contractStatusType = item => {
    if (item.isPaymentTimeout) return "error";
    const amount = Number(item.contractAmount) || 0;
    if (amount > 0 && (Number(item.receiptPaymentAmount) || 0) >= amount) return "success";
    return "primary";
  };
  const fmtMoney = value => {
    const num = Number(value);
    if (!Number.isFinite(num)) return "Â¥ 0.00";
    return "Â¥ " + num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  };
  const calcRate = (paid, amount) => {
    const total = Number(amount);
    if (!Number.isFinite(total) || total === 0) return "0.0";
    return (((Number(paid) || 0) / total) * 100).toFixed(1);
  };
  onLoad(options => {
    projectId.value = options?.projectId || "";
    projectName.value = options?.projectName ? decodeURIComponent(options.projectName) : "";
    if (!projectId.value) {
      uni.showToast({ title: "缺少项目参数", icon: "none" });
      return;
    }
    loadCustomers();
  });
</script>
@@ -249,6 +341,8 @@
    display: flex;
    align-items: center;
    gap: 8px;
    flex: 1;
    min-width: 0;
  }
  .record-icon {
@@ -259,17 +353,16 @@
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .item-index {
    font-size: 14px;
    color: #333;
    font-weight: 500;
  }
  .item-date {
    font-size: 12px;
    color: #666;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .item-details {
src/pages/sales/receiptPaymentLedger/index.vue
@@ -1,167 +1,261 @@
<template>
  <view class="sales-account">
    <!-- ä½¿ç”¨é€šç”¨é¡µé¢å¤´éƒ¨ç»„ä»¶ -->
    <PageHeader title="客户往来"
                @back="goBack" />
    <!-- ç»Ÿè®¡æ¡ï¼ˆéœ€æ±‚七:应收摘要) -->
    <view class="summary-strip">
      <view class="summary-cell">
        <text class="summary-num">{{ summary.totalProjects || 0 }}</text>
        <text class="summary-cap">项目</text>
      </view>
      <view class="summary-cell">
        <text class="summary-num">{{ summary.totalCustomers || 0 }}</text>
        <text class="summary-cap">客户</text>
      </view>
      <view class="summary-cell">
        <text class="summary-num">{{ summary.totalContracts || 0 }}</text>
        <text class="summary-cap">合同</text>
      </view>
      <view class="summary-cell">
        <text class="summary-num">{{ fmtMoney(summary.totalAmount) }}</text>
        <text class="summary-cap">签约总额</text>
      </view>
      <view class="summary-cell overdue">
        <text class="summary-num">{{ summary.totalOverdueCount || 0 }}</text>
        <text class="summary-cap">超30天未回款</text>
      </view>
    </view>
    <!-- æœç´¢åŒºåŸŸ -->
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    placeholder="请输入客户名称"
                    v-model="searchForm.searchText"
                    @change="searchChange"
                    clearable />
          <up-input v-model="query.projectName"
                    class="search-text"
                    placeholder="请输入项目名称搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="search-button"
              @click="handleQuery">
          <up-icon name="search"
        <view class="filter-button"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
                   color="#999" />
        </view>
      </view>
    </view>
    <!-- å®¢æˆ·åˆ—表 -->
    <view class="customer-list-container">
      <view class="customer-list"
            v-if="tableData.length > 0">
        <view v-for="(item, index) in tableData"
              :key="item.id"
              class="customer-item"
              @click="rowClickMethod(item)">
    <!-- é¡¹ç›®åˆ—表 -->
    <view v-if="projectList.length > 0"
          class="ledger-list">
      <view v-for="item in projectList"
            :key="item.projectId"
            class="ledger-item"
            @click="goDetail(item)">
          <view class="item-header">
            <view class="item-left">
              <view class="customer-icon">
            <view class="document-icon">
                <up-icon name="file-text"
                         size="16"
                         color="#ffffff"></up-icon>
                       color="#ffffff" />
              </view>
              <text class="customer-name">{{ item.customerName }}</text>
            <text class="item-id">{{ item.projectName || "-" }}</text>
            </view>
            <view class="item-right">
              <up-icon name="arrow-right"
                       size="16"
                       color="#999"></up-icon>
            </view>
          <up-tag :text="overdueText(item.overdueCount)"
                  :type="item.overdueCount > 0 ? 'error' : 'success'"
                  size="mini"
                  shape="circle" />
          </view>
          <up-divider></up-divider>
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">开票金额(元)</text>
              <text class="detail-value">{{ formattedNumber(item.invoiceTotal) }}</text>
            <text class="detail-label">项目编号</text>
            <text class="detail-value">{{ item.projectCode || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">回款金额(元)</text>
              <text class="detail-value">{{ formattedNumber(item.receiptPaymentAmount) }}</text>
            <text class="detail-label">签约总额</text>
            <text class="detail-value highlight">{{ fmtMoney(item.contractAmounts) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">应收金额(元)</text>
              <text class="detail-value highlight danger">{{ formattedNumber(item.unReceiptPaymentAmount) }}</text>
            <text class="detail-label">开票金额</text>
            <text class="detail-value">{{ fmtMoney(item.invoiceAmount) }}</text>
            </view>
          <view class="detail-row">
            <text class="detail-label">回款金额</text>
            <text class="detail-value highlight">{{ fmtMoney(item.receiptPaymentAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">应收金额</text>
            <text class="detail-value danger">{{ fmtMoney(item.receiptableAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">退货金额</text>
            <text class="detail-value">{{ fmtMoney(item.returnAmount) }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">回款进度</text>
            <text class="detail-value"
                  :class="{ danger: item.overdueCount > 0 }">
              {{ calcRate(item.receiptPaymentAmount, item.contractAmounts) }}%
            </text>
          </view>
          <view class="detail-row">
            <text class="detail-label">客户 / åˆåŒ</text>
            <text class="detail-value">{{ item.customerCount || 0 }} å®¶ / {{ item.contractCount || 0 }} ä»½</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click.stop="goDetail(item)">查看客户往来</up-button>
          </view>
        </view>
      </view>
      <view v-else
            class="no-data">
        <text>暂无客户数据</text>
      <text>暂无项目往来数据</text>
      </view>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="客户往来筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { onMounted, ref, reactive, toRefs } from "vue";
  import { reactive, ref, onMounted } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import { invoiceLedgerSalesAccount } from "@/api/salesManagement/invoiceLedger";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    projectTransactions,
    transactionsSummary,
  } from "@/api/salesManagement/indicatorStats";
  import { unwrapList } from "@/utils/list";
  const tableData = ref([]);
  const projectList = ref([]);
  const summary = ref({});
  const showFilter = ref(false);
  const page = reactive({
    current: -1,
    size: -1,
  const query = reactive({
    projectName: "",
    customerName: "",
    contractName: "",
  });
  const data = reactive({
    searchForm: {
      searchText: "",
      invoiceDate: "",
    },
  });
  const filterFields = [
    { prop: "customerName", label: "客户名称", type: "input", placeholder: "请输入客户名称" },
    { prop: "contractName", label: "合同名称", type: "input", placeholder: "请输入合同名称" },
  ];
  const { searchForm } = toRefs(data);
  // è¿”回上一页
  const goBack = () => {
    uni.navigateBack();
  };
  // æœç´¢æ¡†å˜åŒ–时触发
  const searchChange = val => {
    searchForm.value.searchText = val;
    getList();
  };
  // æŸ¥è¯¢åˆ—表
  const handleQuery = () => {
    getList();
  };
  const getList = () => {
    showLoadingToast("加载中...");
    invoiceLedgerSalesAccount({ ...searchForm.value, ...page })
    uni.showLoading({ title: "加载中...", mask: true });
    const params = { ...query, current: -1, size: -1 };
    projectTransactions(params)
      .then(res => {
        tableData.value = res.data.records;
        closeToast();
        projectList.value = unwrapList(res);
        uni.hideLoading();
      })
      .catch(() => {
        closeToast();
        uni.showToast({
          title: "查询失败",
          icon: "error",
        });
      });
  };
  const formattedNumber = value => {
    return parseFloat(value || 0).toFixed(2);
  };
  // æ˜¾ç¤ºåŠ è½½æç¤º
  const showLoadingToast = message => {
    uni.showLoading({
      title: message,
      mask: true,
    });
  };
  // å…³é—­æç¤º
  const closeToast = () => {
    uni.hideLoading();
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  const rowClickMethod = row => {
    // ä½¿ç”¨ uni.setStorageSync å­˜å‚¨å®¢æˆ·ä¿¡æ¯
    uni.setStorageSync("customerId", row.id);
    // è·³è½¬åˆ°å›žæ¬¾è®°å½•明细页面
  const getSummary = () => {
    transactionsSummary()
      .then(res => {
        summary.value = res.data || {};
      })
      .catch(() => {});
  };
  const onFilterSearch = values => {
    Object.assign(query, values || {});
    getList();
  };
  const goDetail = item => {
    if (!item?.projectId) return;
    uni.navigateTo({
      url: "/pages/sales/receiptPaymentLedger/detail",
      url: `/pages/sales/receiptPaymentLedger/detail?projectId=${item.projectId}`
        + `&projectName=${encodeURIComponent(item.projectName || "")}`,
    });
  };
  const overdueText = count => (count > 0 ? `${count} ä»½è¶…期` : "正常履约");
  const fmtMoney = value => {
    const num = Number(value);
    if (!Number.isFinite(num)) return "Â¥ 0.00";
    return "Â¥ " + num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  };
  const calcRate = (paid, amount) => {
    const total = Number(amount);
    if (!Number.isFinite(total) || total === 0) return "0.0";
    return (((Number(paid) || 0) / total) * 100).toFixed(1);
  };
  onShow(() => {
    // é¡µé¢æ˜¾ç¤ºæ—¶åˆ·æ–°åˆ—表
    getList();
    getSummary();
  });
  onMounted(() => {
    getList();
    getSummary();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  // å®¢æˆ·å¾€æ¥ç‰¹æœ‰æ ·å¼
  .summary-strip {
    display: flex;
    flex-wrap: wrap;
    background: #ffffff;
    margin: 12px 12px 0;
    border-radius: 12px;
    padding: 12px 8px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .summary-cell {
    flex: 1 0 33%;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 6px 0;
  }
  .summary-num {
    font-size: 16px;
    font-weight: 600;
    color: #333333;
  }
  .summary-cap {
    font-size: 11px;
    color: #909399;
    margin-top: 2px;
  }
  .summary-cell.overdue .summary-num {
    color: #ff4757;
  }
  .detail-value.danger {
    color: #ff4757; // ä¸Žå…¬å…±æ ·å¼ä¸­çš„ #ee0a24 ä¸åŒ
    color: #ff4757;
    font-weight: 500;
  }
</style>
src/pages/sales/salesAccount/detail.vue
@@ -41,27 +41,102 @@
        </template>
      </up-form-item>
      <up-form-item label="项目名称"
                    prop="projectName"
                    prop="projectId"
                    required>
        <up-input v-model="form.projectName"
                  placeholder="请输入项目名称" />
        <FormPicker v-model="form.projectId"
                    type="select"
                    title="选择项目"
                    :options="projectOptions"
                    placeholder="请选择项目"
                    @change="onProjectChange" />
      </up-form-item>
      <up-form-item label="签订日期"
                    prop="executionDate"
                    required>
        <up-input v-model="form.executionDate"
                  readonly
                  placeholder="点击选择时间"
                  @click="showDatePicker = true" />
        <template #right>
          <up-icon name="arrow-right"
                   @click="showDatePicker = true"></up-icon>
        </template>
        <FormPicker v-model="form.executionDate"
                    type="date"
                    placeholder="请选择签订日期"
                    @change="onExecutionDateChange" />
      </up-form-item>
      <up-form-item label="年份">
        <up-input :model-value="form.year === '' ? '自动取签订日期' : String(form.year)"
                  disabled />
      </up-form-item>
      <up-form-item label="付款方式"
                    prop="paymentMethod">
        <up-input v-model="form.paymentMethod"
                  placeholder="请输入付款方式" />
        <FormPicker v-model="form.paymentMethod"
                    type="select"
                    title="选择付款方式"
                    :display-text="paymentDisplayText"
                    :options="paymentOptions"
                    placeholder="请选择付款方式" />
      </up-form-item>
      <up-form-item label="交货日期"
                    prop="deliveryDate">
        <FormPicker v-model="form.deliveryDate"
                    type="date"
                    placeholder="请选择交货日期" />
      </up-form-item>
      <up-form-item label="签单单位"
                    prop="signingUnitId">
        <FormPicker v-model="form.signingUnitId"
                    type="select"
                    title="选择签单单位"
                    :options="deptOptions"
                    placeholder="请选择签单单位" />
      </up-form-item>
      <up-form-item label="办事处"
                    prop="officeId">
        <FormPicker v-model="form.officeId"
                    type="select"
                    title="选择办事处"
                    :options="deptOptions"
                    placeholder="请选择办事处" />
      </up-form-item>
      <up-form-item label="组负责人"
                    prop="groupLeaderId">
        <FormPicker v-model="form.groupLeaderId"
                    type="select"
                    title="选择组负责人"
                    :options="userOptions"
                    placeholder="请选择组负责人" />
      </up-form-item>
      <up-form-item label="是否开发票">
        <view class="switch-row">
          <up-switch v-model="form.isInvoice"
                     :activeValue="1"
                     :inactiveValue="0" />
        </view>
      </up-form-item>
      <up-form-item label="是否中标">
        <view class="switch-row">
          <up-switch v-model="form.isBid"
                     :activeValue="1"
                     :inactiveValue="0" />
        </view>
      </up-form-item>
      <up-form-item v-if="form.isBid === 1"
                    label="中标价">
        <up-input v-model="form.bidPrice"
                  type="number"
                  placeholder="请输入中标价" />
      </up-form-item>
      <up-form-item v-else
                    label="未中标原因">
        <up-input v-model="form.unbidReason"
                  placeholder="请输入未中标原因" />
      </up-form-item>
      <up-form-item label="合同原件约定回传日期">
        <FormPicker v-model="form.contractOriginalReturnDate"
                    type="date"
                    placeholder="请选择合同原件约定回传日期" />
      </up-form-item>
      <up-form-item label="备注">
        <up-textarea v-model="form.remarks"
                     placeholder="请输入备注"
                     :maxlength="200"
                     count
                     auto-height />
      </up-form-item>
      <up-form-item label="录入人"
                    prop="entryPersonName">
@@ -81,16 +156,6 @@
                       title="选择业务员"
                       @select="onSalesmanSelect"
                       @close="showPicker = false" />
      <!-- æ—¥æœŸé€‰æ‹© -->
      <up-popup :show="showDatePicker"
                mode="bottom"
                @close="showDatePicker = false">
        <up-datetime-picker :show="true"
                            v-model="pickerDateValue"
                            @confirm="onDateConfirm"
                            @cancel="showDatePicker = false"
                            mode="date" />
      </up-popup>
      <!-- å®¢æˆ·é€‰æ‹© -->
      <up-action-sheet :show="showCustomerPicker"
                       :actions="customerActionList"
@@ -283,6 +348,18 @@
            </up-form-item>
          </view>
        </view>
        <!-- éœ€æ±‚五.4:签约金额 = å«ç¨Žå•ä»· Ã— æ•°é‡ï¼Œå³å«ç¨Žæ€»ä»·ï¼ŒæŒ‰äº§å“è¡Œæ±‡æ€» -->
        <view v-if="productData.length > 0"
              class="product-summary">
          <view class="summary-row">
            <text class="summary-label">签约金额合计(元)</text>
            <text class="summary-value highlight">{{ productTotals.taxInclusive }}</text>
          </view>
          <view class="summary-row">
            <text class="summary-label">不含税总价合计(元)</text>
            <text class="summary-value">{{ productTotals.taxExclusive }}</text>
          </view>
        </view>
      </view>
    </up-form>
    <!-- ä½¿ç”¨å…¬å…±åº•部按钮组件 -->
@@ -297,7 +374,8 @@
<script setup>
  import { onMounted, ref, computed } from "vue";
  import { userListNoPage } from "@/api/system/user";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess";
  import {
    addOrUpdateSalesLedger,
    customerList,
@@ -305,10 +383,33 @@
    modelList,
    productTreeList,
  } from "@/api/salesManagement/salesLedger";
  import { getProject } from "@/api/projectManagement/project";
  import useUserStore from "@/store/modules/user";
  import { calculateTaxExclusiveTotalPrice } from "@/utils/summarizeTable";
  import { useDict } from "@/utils/dict";
  import { loadProjectOptions } from "@/utils/projectOptions";
  import { takePrefillProject } from "@/utils/prefill";
  import FormPicker from "@/components/FormPicker.vue";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  const { checkout_payment } = useDict("checkout_payment");
  const paymentOptions = computed(() =>
    (checkout_payment.value || []).map(item => ({
      label: item.label,
      value: item.value,
    }))
  );
  // æ”¹åŠ¨å‰ä»˜æ¬¾æ–¹å¼æ˜¯è‡ªç”±æ–‡æœ¬ï¼Œè€å•æ®çš„å€¼å¯èƒ½ä¸åœ¨å­—å…¸é‡Œï¼Œæ­¤æ—¶å›žæ˜¾åŽŸå€¼è€Œä¸æ˜¯ç©ºç™½
  const paymentDisplayText = computed(() => {
    const value = form.value.paymentMethod;
    if (!value) return "";
    const matched = paymentOptions.value.find(
      item => String(item.value) === String(value)
    );
    return matched ? matched.label : String(value);
  });
  // èŽ·å–é¡µé¢å‚æ•°
  const operationType = ref("");
@@ -319,47 +420,47 @@
  const form = ref({
    id: "",
    salesContractNo: "",
    autoGenerateContractNo: true,
    customerId: "",
    customerName: "",
    projectId: "",
    projectName: "",
    executionDate: "",
    year: "",
    paymentMethod: "",
    deliveryDate: "",
    signingUnitId: "",
    officeId: "",
    groupLeaderId: "",
    isInvoice: 0,
    isBid: 0,
    bidPrice: "",
    unbidReason: "",
    contractOriginalReturnDate: "",
    remarks: "",
    entryPerson: "",
    entryPersonName: "",
    entryDate: "",
  });
  const showPicker = ref(false);
  const showDatePicker = ref(false);
  const pickerDateValue = ref(Date.now());
  const showCustomerPicker = ref(false);
  const userList = ref([]);
  const customerOption = ref([]);
  const projectOptions = ref([]);
  const deptOptions = ref([]);
  const userOptions = ref([]);
  const userActionList = computed(() => {
    return userList.value.map(user => ({
      name: user.text,
      value: user.value,
    }));
  });
  const formatter = (type, value) => {
    if (type === "year") {
      return `${value}`;
    }
    if (type === "month") {
      return `${value}`;
    }
    if (type === "day") {
      return `${value}`;
    }
    return value;
  };
  const customerActionList = computed(() => {
    return customerOption.value.map(customer => ({
      name: customer.text,
      value: customer.value,
    }));
  });
  // æ—¥æœŸé€‰æ‹©åˆ—表已移除,改用 up-datetime-picker
  // äº§å“å¤§ç±»é€‰æ‹©åˆ—表
  const categoryActionList = computed(() => {
@@ -444,7 +545,7 @@
    customerName: [
      { required: true, message: "请选择客户名称", trigger: "change" },
    ],
    projectName: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
    projectId: [{ required: true, message: "请选择项目", trigger: "change" }],
    executionDate: [
      { required: true, message: "请选择签订日期", trigger: "change" },
    ],
@@ -473,18 +574,85 @@
    form.value.salesman = item.name;
  };
  // æ—¥æœŸç¡®è®¤äº‹ä»¶
  const onDateConfirm = e => {
    form.value.executionDate = formatDateToYMD(e.value);
    // ä¿æŒpickerDateValue为时间戳格式,而不是格式化的字符串
    pickerDateValue.value = e.value;
    showDatePicker.value = false;
  // ç­¾è®¢æ—¥æœŸ â†’ å¹´ä»½è‡ªåŠ¨å–å¹´ä»½ï¼ˆç®¡ç†ç«¯åŒæ­¤é€»è¾‘ï¼Œå¹´ä»½åªè¯»ï¼‰
  const onExecutionDateChange = value => {
    form.value.year = value ? Number(String(value).substring(0, 4)) : "";
  };
  // å®¢æˆ·é€‰æ‹©äº‹ä»¶
  const onCustomerSelect = item => {
    form.value.customerName = item.name;
    form.value.customerId = item.value;
  };
  // éœ€æ±‚五.4:签约金额 = å«ç¨Žå•ä»· Ã— æ•°é‡ï¼Œé€è¡Œæ±‡æ€»
  const productTotals = computed(() => {
    const sumBy = key =>
      (productData.value || [])
        .reduce((sum, row) => sum + (Number(row?.[key]) || 0), 0)
        .toFixed(2);
    return {
      taxInclusive: sumBy("taxInclusiveTotalPrice"),
      taxExclusive: sumBy("taxExclusiveTotalPrice"),
    };
  });
  // éœ€æ±‚五.1:选项目后带出客户与产品明细(管理端 fetchAndPopulateProject åŒé€»è¾‘)
  const applyProject = async id => {
    const matched = projectOptions.value.find(
      item => String(item.value) === String(id)
    );
    form.value.projectName = matched?.name || "";
    if (!id) {
      productData.value = [];
      return;
    }
    try {
      const res = await getProject(id);
      const detail = res?.data?.data ?? res?.data ?? res;
      const info = detail?.info || {};
      if (info.clientId) form.value.customerId = info.clientId;
      if (info.clientName) form.value.customerName = info.clientName;
      const products = detail?.salesLedgerProductList;
      productData.value = Array.isArray(products)
        ? products.map(item => ({
            ...item,
            id: undefined,
            salesLedgerId: undefined,
          }))
        : [];
    } catch (error) {
      uni.showToast({ title: "获取项目信息失败", icon: "none" });
    }
  };
  // v-model å·²å…ˆå†™å…¥ projectId,取消时要回滚到上一个项目
  const previousProjectId = ref("");
  const confirmProjectChange = id => {
    previousProjectId.value = id;
    applyProject(id);
  };
  const onProjectChange = id => {
    if (productData.value && productData.value.length > 0) {
      uni.showModal({
        title: "提示",
        content: "当前已录入产品明细,切换项目将被覆盖,是否继续?",
        success: res => {
          if (res.confirm) {
            confirmProjectChange(id);
          } else {
            form.value.projectId = previousProjectId.value;
          }
        },
      });
      return;
    }
    confirmProjectChange(id);
  };
  // åŽŸæœ‰çš„ç¡®è®¤æ–¹æ³•å·²è¢«æ–°çš„action-sheet选择方法替代
@@ -820,33 +988,55 @@
    const month = String(today.getMonth() + 1).padStart(2, "0");
    const day = String(today.getDate()).padStart(2, "0");
    form.value.entryDate = `${year}-${month}-${day}`;
    // è®¾ç½®æ—¥æœŸé€‰æ‹©å™¨é»˜è®¤å€¼ä¸ºä»Šå¤©
    pickerDateValue.value = `${year}-${month}-${day}`;
  };
  // å¡«å……表单数据(编辑模式)
  const fillFormData = () => {
    if (!editData.value) return;
    getSalesLedgerWithProducts({ id: editData.value.id, type: 1 }).then(res => {
      productData.value = res.productData;
    });
    console.log(editData.value);
    // å¡«å……基本信息
    form.value.salesContractNo = editData.value.salesContractNo || "";
    form.value.customerName = editData.value.customerName || "";
    form.value.projectName = editData.value.projectName || "";
    form.value.executionDate = editData.value.executionDate || "";
    form.value.paymentMethod = editData.value.paymentMethod || "";
    form.value.salesman = editData.value.salesman || "";
    form.value.entryPerson = editData.value.entryPerson || "";
    form.value.entryPersonName = editData.value.entryPersonName || "";
    form.value.entryDate = editData.value.entryDate || "";
    form.value.id = editData.value.id || "";
    form.value.customerId = editData.value.customerId || "";
    const row = editData.value;
    // è®¾ç½®æ—¥æœŸé€‰æ‹©å™¨çš„值
    if (editData.value.executionDate) {
      pickerDateValue.value = editData.value.executionDate;
    // å…ˆç”¨åˆ—表行同步铺一遍,保证请求返回前表单不是空的
    form.value.salesContractNo = row.salesContractNo || "";
    form.value.customerName = row.customerName || "";
    form.value.customerId = row.customerId || "";
    form.value.projectId = row.projectId ?? "";
    form.value.projectName = row.projectName || "";
    form.value.executionDate = row.executionDate || "";
    form.value.paymentMethod = row.paymentMethod || "";
    form.value.deliveryDate = row.deliveryDate || "";
    form.value.signingUnitId = row.signingUnitId ?? "";
    form.value.officeId = row.officeId ?? "";
    form.value.groupLeaderId = row.groupLeaderId ?? "";
    form.value.isInvoice = row.isInvoice ?? 0;
    form.value.isBid = row.isBid ?? 0;
    form.value.bidPrice = row.bidPrice ?? "";
    form.value.unbidReason = row.unbidReason || "";
    form.value.contractOriginalReturnDate =
      row.contractOriginalReturnDate || "";
    form.value.remarks = row.remarks || "";
    form.value.salesman = row.salesman || "";
    form.value.entryPerson = row.entryPerson || "";
    form.value.entryPersonName = row.entryPersonName || "";
    form.value.entryDate = row.entryDate || "";
    form.value.year = row.year || "";
    form.value.id = row.id || "";
    previousProjectId.value = form.value.projectId;
    // è¯¦æƒ…接口字段比列表行全,再整体覆盖一次补齐新增字段
    getSalesLedgerWithProducts({ id: row.id, type: 1 })
      .then(res => {
        const data = res?.data ?? res ?? {};
        productData.value = data.productData || [];
        form.value = { ...form.value, ...data, id: row.id };
        previousProjectId.value = form.value.projectId ?? "";
        if (form.value.executionDate) {
          form.value.year = Number(
            String(form.value.executionDate).substring(0, 4)
          );
    }
      })
      .catch(() => {
        uni.showToast({ title: "获取台账详情失败", icon: "none" });
      });
  };
  const getUserList = () => {
    userListNoPage().then(res => {
@@ -896,6 +1086,46 @@
    });
  };
  // é¡¹ç›®ä¸‹æ‹‰ï¼ˆéœ€æ±‚五.1 çš„联动入口)
  const getProjectOptions = () => {
    loadProjectOptions()
      .then(options => {
        projectOptions.value = options;
      })
      .catch(() => {
        projectOptions.value = [];
      });
  };
  // ç­¾å•单位 / åŠžäº‹å¤„å…±ç”¨éƒ¨é—¨ä¸‹æ‹‰ï¼Œç»„è´Ÿè´£äººç”¨å‘˜å·¥ä¸‹æ‹‰
  const getOrgOptions = async () => {
    const [depts, users] = await Promise.all([
      getDept().catch(() => ({})),
      userListNoPageByTenantId().catch(() => ({})),
    ]);
    deptOptions.value = (depts?.data || []).map(item => ({
      name: item.deptName,
      value: item.deptId,
    }));
    const rows = users?.data || [];
    userOptions.value = (Array.isArray(rows) ? rows : []).map(item => ({
      name: item.nickName || item.userName || `用户${item.userId}`,
      value: item.userId,
    }));
  };
  // éœ€æ±‚三.6:招投标中标后带项目信息跳进来(utils/prefill.js å†™å…¥ï¼‰
  const applyPrefillProject = () => {
    const prefill = takePrefillProject();
    if (!prefill) return;
    form.value.projectId = prefill.projectId ?? "";
    form.value.projectName = prefill.projectName || "";
    if (prefill.customerId) form.value.customerId = prefill.customerId;
    if (prefill.customerName) form.value.customerName = prefill.customerName;
    previousProjectId.value = form.value.projectId;
    if (form.value.projectId) applyProject(form.value.projectId);
  };
  onMounted(() => {
    // èŽ·å–é¡µé¢å‚æ•°
    operationType.value = uni.getStorageSync("operationType") || "";
@@ -906,9 +1136,13 @@
    getCustomerList();
    // èŽ·å–äº§å“å¤§ç±»tree数据
    getProductOptions();
    // èŽ·å–é¡¹ç›® / éƒ¨é—¨ / å‘˜å·¥ä¸‹æ‹‰
    getProjectOptions();
    getOrgOptions();
    // èµ‹å€¼é»˜è®¤ä¿¡æ¯
    if (operationType.value === "add") {
      setUserInfo();
      applyPrefillProject();
    }
    // èŽ·å–ç¼–è¾‘æ•°æ®å¹¶å¡«å……è¡¨å•
@@ -932,4 +1166,41 @@
<style lang="scss">
  @import "@/static/scss/form-common.scss";
  .switch-row {
    display: flex;
    justify-content: flex-end;
    width: 100%;
  }
  .product-summary {
    margin: 12px 0 0;
    padding: 12px 14px;
    background: #f8fbff;
    border-radius: 10px;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .summary-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .summary-label {
    font-size: 14px;
    color: #606266;
  }
  .summary-value {
    font-size: 15px;
    font-weight: 600;
    color: #22324d;
  }
  .summary-value.highlight {
    color: #2979ff;
  }
</style>
src/pages/sales/salesAccount/goOut.vue
@@ -82,6 +82,50 @@
            </view>
          </view>
        </u-cell-group>
        <!-- å•据与归档(需求六) -->
        <u-cell-group title="单据与归档"
                      class="form-section">
          <up-form-item label="发货单据"
                        prop="shippingDocument">
            <up-input v-model="form.shippingDocument"
                      placeholder="请输入发货单据"
                      clearable />
          </up-form-item>
          <up-form-item label="是否对账"
                        prop="isReconciled">
            <up-switch v-model="form.isReconciled"
                       :active-value="1"
                       :inactive-value="0" />
          </up-form-item>
          <up-form-item v-if="form.isReconciled === 1"
                        label="对账单归档"
                        prop="statementArchivingStatus">
            <up-textarea v-model="form.statementArchivingStatus"
                         placeholder="请填写对账单归档情况"
                         auto-height
                         maxlength="200" />
          </up-form-item>
          <up-form-item label="是否结算"
                        prop="isSettled">
            <up-switch v-model="form.isSettled"
                       :active-value="1"
                       :inactive-value="0" />
          </up-form-item>
          <up-form-item v-if="form.isSettled === 1"
                        label="结算单归档"
                        prop="settlementArchivingStatus">
            <up-textarea v-model="form.settlementArchivingStatus"
                         placeholder="请填写结算单归档情况"
                         auto-height
                         maxlength="200" />
          </up-form-item>
          <up-form-item label="是否已开票"
                        prop="isInvoiced">
            <up-switch v-model="form.isInvoiced"
                       :active-value="1"
                       :inactive-value="0" />
          </up-form-item>
        </u-cell-group>
        <!-- å‘货图片 -->
        <u-cell-group title="发货图片"
                      class="form-section">
@@ -133,6 +177,12 @@
    shippingCarNumber: "",
    expressCompany: "",
    expressNumber: "",
    shippingDocument: "",
    isReconciled: 0,
    statementArchivingStatus: "",
    isSettled: 0,
    settlementArchivingStatus: "",
    isInvoiced: 0,
  });
  const rules = {
@@ -344,6 +394,16 @@
        quantity: parseFloat(b.deliveryQuantity),
        productModelId: goOutData.value.productModelId,
      })),
      // éœ€æ±‚六:发货时同步登记单据与对账/结算/开票情况,
      // åŽç«¯æœªæŽ¥æ”¶æ—¶ä¼šè¢«å¿½ç•¥ï¼Œä¸å½±å“å‘货主流程
      shippingDocument: form.shippingDocument,
      isReconciled: form.isReconciled,
      statementArchivingStatus:
        form.isReconciled === 1 ? form.statementArchivingStatus : "",
      isSettled: form.isSettled,
      settlementArchivingStatus:
        form.isSettled === 1 ? form.settlementArchivingStatus : "",
      isInvoiced: form.isInvoiced,
    };
    try {
src/pages/sales/salesAccount/index.vue
@@ -90,7 +90,7 @@
                        @click.stop="openOut(item)">
                å‘货状态
              </u-button>
              <!-- <u-button class="detail-button"
              <u-button class="detail-button"
                        size="small"
                        type="primary"
                        @click.stop="handleInfo('edit', item)">
@@ -98,18 +98,9 @@
              </u-button>
              <u-button class="detail-button"
                        size="small"
                        type="primary"
                        plain
                        @click.stop="openOut(item)">
                å‘货状态
              </u-button> -->
              <!-- <u-button class="detail-button"
                        size="small"
                        type="error"
                        plain
                        @click.stop="handleDelete(item)">
                åˆ é™¤
              </u-button> -->
                        @click.stop="openDetail(item)">
                è¯¦æƒ…
              </u-button>
            </view>
          </view>
        </view>
@@ -120,12 +111,12 @@
      <text>暂无销售台账数据</text>
    </view>
    <!-- æµ®åŠ¨æ“ä½œæŒ‰é’® -->
    <!-- <view class="fab-button"
    <view class="fab-button"
          @click="handleInfo('add')">
      <up-icon name="plus"
               size="24"
               color="#ffffff"></up-icon>
    </view> -->
    </view>
  </view>
</template>
@@ -203,6 +194,14 @@
    });
  };
  // åªè¯»è¯¦æƒ…:view.vue ä¼šæŒ‰ id é‡æ–°æ‹‰ä¸€éå®Œæ•´å°è´¦ï¼Œåˆ—表行只作占位
  const openDetail = item => {
    uni.setStorageSync("editData", JSON.stringify(item));
    uni.navigateTo({
      url: "/pages/sales/salesAccount/view",
    });
  };
  // åˆ é™¤å•条销售台账
  const handleDelete = async row => {
    if (!row || !row.id) return;
src/pages/sales/salesAccount/view.vue
@@ -28,8 +28,46 @@
          <text class="info-value">{{ form.executionDate }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">年份</text>
          <text class="info-value">{{ form.year || "-" }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">付款方式</text>
          <text class="info-value">{{ form.paymentMethod }}</text>
          <text class="info-value">{{ dictLabel(form.paymentMethod) }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">交货日期</text>
          <text class="info-value">{{ form.deliveryDate || "-" }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">签单单位</text>
          <text class="info-value">{{ deptName(form.signingUnitId) }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">办事处</text>
          <text class="info-value">{{ deptName(form.officeId) }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">组负责人</text>
          <text class="info-value">{{ userName(form.groupLeaderId) }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">是否开发票</text>
          <text class="info-value">{{ form.isInvoice === 1 ? "是" : "否" }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">是否中标</text>
          <text class="info-value">{{ form.isBid === 1 ? "是" : "否" }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">{{ form.isBid === 1 ? "中标价" : "未中标原因" }}</text>
          <text class="info-value">
            {{ form.isBid === 1 ? form.bidPrice ?? "-" : form.unbidReason || "-" }}
          </text>
        </view>
        <view class="info-item">
          <text class="info-label">合同原件约定回传日期</text>
          <text class="info-value">{{ form.contractOriginalReturnDate || "-" }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">录入人</text>
@@ -38,6 +76,11 @@
        <view class="info-item">
          <text class="info-label">录入日期</text>
          <text class="info-value">{{ form.entryDate }}</text>
        </view>
        <view v-if="form.remarks"
              class="info-item remarks-item">
          <text class="info-label">备注</text>
          <text class="info-value">{{ form.remarks }}</text>
        </view>
      </view>
    </view>
@@ -107,13 +150,30 @@
          class="no-product">
      <text>暂无产品信息</text>
    </view>
    <!-- éœ€æ±‚五.4:签约金额 = å«ç¨Žå•ä»· Ã— æ•°é‡ï¼ŒæŒ‰äº§å“è¡Œæ±‡æ€»ï¼ˆå¯¹é½ç®¡ç†ç«¯ summarizeMainTable) -->
    <view v-if="productData.length > 0"
          class="product-summary">
      <view class="summary-row">
        <text class="summary-label">签约金额合计(元)</text>
        <text class="summary-value highlight">{{ productTotals.taxInclusive }}</text>
      </view>
      <view class="summary-row">
        <text class="summary-label">不含税总价合计(元)</text>
        <text class="summary-value">{{ productTotals.taxExclusive }}</text>
      </view>
    </view>
  </view>
</template>
<script setup>
  import { onMounted, ref } from "vue";
  import { computed, onMounted, ref } from "vue";
  import { getSalesLedgerWithProducts } from "@/api/salesManagement/salesLedger";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { useDict } from "@/utils/dict";
  import PageHeader from "@/components/PageHeader.vue";
  const { checkout_payment } = useDict("checkout_payment");
  // èŽ·å–é¡µé¢å‚æ•°
  const editData = ref(null);
@@ -134,6 +194,35 @@
  // äº§å“æ•°æ®
  const productData = ref([]);
  const deptOptions = ref([]);
  const userOptions = ref([]);
  const dictLabel = value => {
    if (value === undefined || value === null || value === "") return "-";
    const matched = (checkout_payment.value || []).find(
      item => String(item.value) === String(value)
    );
    return matched ? matched.label : String(value);
  };
  // åŽç«¯åªå­˜ id,只读页需要自己反查部门 / å‘˜å·¥åç§°
  const deptName = id =>
    deptOptions.value.find(item => String(item.value) === String(id))?.name ||
    "-";
  const userName = id =>
    userOptions.value.find(item => String(item.value) === String(id))?.name ||
    "-";
  const productTotals = computed(() => {
    const sumBy = key =>
      (productData.value || [])
        .reduce((sum, row) => sum + (Number(row?.[key]) || 0), 0)
        .toFixed(2);
    return {
      taxInclusive: sumBy("taxInclusiveTotalPrice"),
      taxExclusive: sumBy("taxExclusiveTotalPrice"),
    };
  });
  // è¿”回上一页
  const goBack = () => {
@@ -153,7 +242,24 @@
    });
  };
  const loadOrgOptions = async () => {
    const [depts, users] = await Promise.all([
      getDept().catch(() => ({})),
      userListNoPageByTenantId().catch(() => ({})),
    ]);
    deptOptions.value = (depts?.data || []).map(item => ({
      name: item.deptName,
      value: item.deptId,
    }));
    const rows = users?.data || [];
    userOptions.value = (Array.isArray(rows) ? rows : []).map(item => ({
      name: item.nickName || item.userName || `用户${item.userId}`,
      value: item.userId,
    }));
  };
  onMounted(() => {
    loadOrgOptions();
    // èŽ·å–ç¼–è¾‘æ•°æ®å¹¶å¡«å……è¡¨å•
    const editDataStr = uni.getStorageSync("editData");
    if (editDataStr) {
@@ -290,4 +396,40 @@
    color: #999;
    font-size: 0.875rem;
  }
  .remarks-item {
    grid-column: 1 / -1;
  }
  .product-summary {
    background: #fff;
    margin: 1rem;
    padding: 1rem;
    border-radius: 0.5rem;
    box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.04);
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
  }
  .summary-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .summary-label {
    font-size: 0.8125rem;
    color: #666;
  }
  .summary-value {
    font-size: 0.875rem;
    font-weight: 600;
    color: #333;
  }
  .summary-value.highlight {
    color: #2979ff;
  }
</style>
src/pages/sales/salesQuotation/detail.vue
@@ -15,6 +15,10 @@
            <text class="info-value">{{ detailData.customer || "-" }}</text>
          </view>
          <view class="info-item">
            <text class="info-label">所属项目</text>
            <text class="info-value">{{ detailData.projectName || "-" }}</text>
          </view>
          <view class="info-item">
            <text class="info-label">业务员</text>
            <text class="info-value">{{ detailData.salesperson || "-" }}</text>
          </view>
src/pages/sales/salesQuotation/edit.vue
@@ -23,6 +23,16 @@
                       @click="showCustomerSheet = true"></up-icon>
            </template>
          </up-form-item>
          <up-form-item label="所属项目"
                        prop="projectId">
            <FormPicker v-model="form.projectId"
                        :display-text="form.projectName"
                        type="select"
                        title="选择所属项目"
                        :options="projectOptions"
                        placeholder="请选择所属项目"
                        @change="onProjectChange" />
          </up-form-item>
          <up-form-item label="业务员"
                        prop="salesperson"
                        required>
@@ -199,7 +209,9 @@
  import { onLoad } from "@dcloudio/uni-app";
  import FooterButtons from "@/components/FooterButtons.vue";
  import PageHeader from "@/components/PageHeader.vue";
  import FormPicker from "@/components/FormPicker.vue";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { loadProjectOptions, pickProjectName } from "@/utils/projectOptions";
  import { modelList, productTreeList } from "@/api/basicData/product";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import {
@@ -224,6 +236,7 @@
  const salespersonList = ref([]);
  const productList = ref([]);
  const modelActions = ref([]);
  const projectOptions = ref([]);
  let uidSeed = 1;
@@ -232,6 +245,8 @@
    quotationNo: "",
    customerId: undefined,
    customer: "",
    projectId: "",
    projectName: "",
    salesperson: "",
    quotationDate: "",
    validDate: "",
@@ -420,6 +435,31 @@
    );
  };
  const onProjectChange = (value, item) => {
    form.value.projectName = pickProjectName(item);
  };
  const loadProjectList = async () => {
    try {
      projectOptions.value = await loadProjectOptions();
    } catch (error) {
      projectOptions.value = [];
    }
  };
  // æŠ¥ä»·å•只带 projectId ä¸å¸¦åç§°æ—¶ï¼Œä»Žä¸‹æ‹‰é€‰é¡¹åæŸ¥è¡¥å…¨ï¼Œé¿å…ä¿å­˜æ—¶æŠŠåç§°æ¸…空
  const resolveProjectName = () => {
    if (form.value.projectName) return;
    if (!form.value.projectId) {
      form.value.projectName = "";
      return;
    }
    const matched = projectOptions.value.find(
      option => String(option.value) === String(form.value.projectId)
    );
    form.value.projectName = matched ? matched.name : "";
  };
  // æ ¹æ®åç§°åæŸ¥èŠ‚ç‚¹ id,便于仅存名称时的反显
  const findNodeIdByLabel = (nodes, label) => {
    if (!label) return null;
@@ -498,6 +538,8 @@
        quotationNo: data.quotationNo || "",
        customerId: data.customerId,
        customer: data.customer || "",
        projectId: data.projectId ?? "",
        projectName: data.projectName || "",
        salesperson: data.salesperson || "",
        quotationDate: data.quotationDate || "",
        validDate: data.validDate || "",
@@ -546,6 +588,7 @@
    // åŒæ­¥æœ€æ–°çš„æ€»é¢
    form.value.totalAmount = totalAmount.value;
    form.value.subtotal = totalAmount.value;
    resolveProjectName();
    const payload = {
      ...form.value,
@@ -586,7 +629,7 @@
  });
  onMounted(async () => {
    await fetchBaseOptions();
    await Promise.all([fetchBaseOptions(), loadProjectList()]);
    if (quotationId.value) {
      await loadDetail();
    }
src/pages/sales/salesQuotation/index.vue
@@ -6,14 +6,14 @@
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="quotationNo"
                    v-model="query.quotationNo"
                    placeholder="请输入报价单号搜索"
                    clearable
                    @change="getList" />
        </view>
        <view class="filter-button"
              @click="getList">
          <up-icon name="search"
              @click="showFilter = true">
          <up-icon name="list"
                   size="24"
                   color="#999"></up-icon>
        </view>
@@ -49,6 +49,10 @@
          <view class="detail-row">
            <text class="detail-label">客户名称</text>
            <text class="detail-value">{{ item.customer || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">所属项目</text>
            <text class="detail-value">{{ item.projectName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">业务员</text>
@@ -103,20 +107,50 @@
               size="28"
               color="#ffffff"></up-icon>
    </view>
    <FilterPanel v-model:show="showFilter"
                 :model-value="query"
                 title="报价筛选"
                 :fields="filterFields"
                 @search="onFilterSearch"
                 @reset="onFilterSearch" />
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { computed, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FilterPanel from "@/components/FilterPanel.vue";
  import {
    deleteQuotation,
    getQuotationList,
  } from "@/api/salesManagement/salesQuotation";
  import { loadProjectOptions } from "@/utils/projectOptions";
  const quotationNo = ref("");
  const quotationList = ref([]);
  const showFilter = ref(false);
  const projectOptions = ref([]);
  const query = reactive({
    quotationNo: "",
    projectId: "",
  });
  const filterFields = computed(() => [
    {
      prop: "quotationNo",
      label: "报价单号",
      type: "input",
      placeholder: "请输入报价单号",
    },
    {
      prop: "projectId",
      label: "所属项目",
      type: "select",
      options: projectOptions.value,
    },
  ]);
  const tabList = reactive([
    { name: "全部", value: "" },
@@ -183,7 +217,8 @@
    uni.showLoading({ title: "加载中...", mask: true });
    getQuotationList({
      ...page,
      quotationNo: quotationNo.value,
      quotationNo: query.quotationNo,
      projectId: query.projectId || undefined,
      status: getCurrentStatus(),
    })
      .then(res => {
@@ -195,6 +230,26 @@
      })
      .finally(() => {
        uni.hideLoading();
      });
  };
  // FilterPanel åªå›žåç­›é€‰å€¼ï¼Œå†™å›ž query åŽç»Ÿä¸€æŸ¥è¯¢
  const onFilterSearch = values => {
    Object.assign(query, values);
    getList();
  };
  let projectsLoaded = false;
  const loadProjects = () => {
    if (projectsLoaded) return;
    projectsLoaded = true;
    loadProjectOptions()
      .then(options => {
        projectOptions.value = options;
      })
      .catch(() => {
        projectsLoaded = false;
      });
  };
@@ -223,6 +278,7 @@
  onShow(() => {
    getList();
    loadProjects();
  });
</script>
src/pages/works.vue
@@ -23,6 +23,29 @@
        </up-grid>
      </view>
    </view>
    <!-- é¡¹ç›®ç®¡ç†æ¨¡å— -->
    <view class="common-module project-module"
          v-if="hasProjectItems">
      <view class="module-header">
        <view class="module-title-container">
          <text class="module-title">项目管理</text>
        </view>
      </view>
      <view class="module-content">
        <up-grid :border="false"
                 col="4">
          <up-grid-item v-for="(item, index) in projectItems"
                        :key="index"
                        @click="handleCommonItemClick(item)">
            <view class="icon-container">
              <image :src="item.icon"
                     class="item-icon"></image>
            </view>
            <text class="item-label">{{item.label}}</text>
          </up-grid-item>
        </up-grid>
      </view>
    </view>
    <!-- è¥é”€ç®¡ç†æ¨¡å— -->
    <view class="common-module marketing-module"
          v-if="hasMarketingItems">
@@ -276,6 +299,29 @@
        </up-grid>
      </view>
    </view>
    <!-- è½¦è¾†ç®¡ç†æ¨¡å— -->
    <view class="common-module vehicle-module"
          v-if="hasVehicleItems">
      <view class="module-header">
        <view class="module-title-container">
          <text class="module-title">车辆管理</text>
        </view>
      </view>
      <view class="module-content">
        <up-grid :border="false"
                 col="4">
          <up-grid-item v-for="(item, index) in vehicleItems"
                        :key="index"
                        @click="handleCommonItemClick(item)">
            <view class="icon-container">
              <image :src="item.icon"
                     class="item-icon"></image>
            </view>
            <text class="item-label">{{item.label}}</text>
          </up-grid-item>
        </up-grid>
      </view>
    </view>
    <!-- å®‰å…¨ç”Ÿäº§æ¨¡å— -->
    <view class="common-module collaboration-module"
          v-if="hasSafetyItems">
@@ -353,6 +399,30 @@
    },
  ]);
  // é¡¹ç›®ç®¡ç†åŠŸèƒ½æ•°æ®
  const projectItems = reactive([
    {
      icon: "/static/images/icon/xiangmuguanli.svg",
      label: "项目管理",
    },
  ]);
  // è½¦è¾†ç®¡ç†åŠŸèƒ½æ•°æ®
  const vehicleItems = reactive([
    {
      icon: "/static/images/icon/cheliangguanli.svg",
      label: "车辆管理",
    },
    {
      icon: "/static/images/icon/jiayoukaguanli.svg",
      label: "加油卡管理",
    },
    {
      icon: "/static/images/icon/jiayoujilu.svg",
      label: "加油记录",
    },
  ]);
  // è¥é”€ç®¡ç†åŠŸèƒ½æ•°æ®
  const marketingItems = reactive([
    {
@@ -370,6 +440,22 @@
    {
      icon: "/static/images/icon/kehuwanglai.svg",
      label: "客户往来",
    },
    {
      icon: "/static/images/icon/zhaotoubiaoguanli.svg",
      label: "招投标管理",
    },
    {
      icon: "/static/images/icon/susongguanli.svg",
      label: "诉讼管理",
    },
    {
      icon: "/static/images/icon/fahuotaizhang.svg",
      label: "发货台账",
    },
    {
      icon: "/static/images/icon/hetongguanli.svg",
      label: "销售合同",
    },
  ]);
@@ -515,6 +601,10 @@
      icon: "/static/images/icon/chuchangjianyan.svg",
      label: "出厂检验",
    },
    {
      icon: "/static/images/icon/baobiaoguanli.svg",
      label: "报表管理",
    },
  ]);
  const safetyItems = reactive([
    {
@@ -609,6 +699,14 @@
      icon: "/static/images/icon/baoxiaoguanli.svg",
      label: "费用报销",
      path: "/pages/oa/ReimburseManage/cost-reimburse/index",
    },
    {
      icon: "/static/images/icon/kuandaiyutongxin.svg",
      label: "宽带与通信管理",
    },
    {
      icon: "/static/images/icon/fangwuzulin.svg",
      label: "房屋租赁管理",
    },
  ]);
@@ -759,6 +857,61 @@
      case "客户往来":
        uni.navigateTo({
          url: "/pages/sales/receiptPaymentLedger/index",
        });
        break;
      case "项目管理":
        uni.navigateTo({
          url: "/pages/projectManagement/project/index",
        });
        break;
      case "招投标管理":
        uni.navigateTo({
          url: "/pages/sales/biddingManagement/index",
        });
        break;
      case "诉讼管理":
        uni.navigateTo({
          url: "/pages/sales/lawsuitManagement/index",
        });
        break;
      case "发货台账":
        uni.navigateTo({
          url: "/pages/sales/deliveryLedger/index",
        });
        break;
      case "销售合同":
        uni.navigateTo({
          url: "/pages/sales/contractManagement/index",
        });
        break;
      case "报表管理":
        uni.navigateTo({
          url: "/pages/qualityManagement/inspectionReport/index",
        });
        break;
      case "车辆管理":
        uni.navigateTo({
          url: "/pages/inventoryManagement/vehicleInfo/index",
        });
        break;
      case "加油卡管理":
        uni.navigateTo({
          url: "/pages/inventoryManagement/fuelCard/index",
        });
        break;
      case "加油记录":
        uni.navigateTo({
          url: "/pages/inventoryManagement/fuelRecord/index",
        });
        break;
      case "宽带与通信管理":
        uni.navigateTo({
          url: "/pages/cooperativeOffice/broadbandCommunication/index",
        });
        break;
      case "房屋租赁管理":
        uni.navigateTo({
          url: "/pages/cooperativeOffice/houseLeaseManagement/index",
        });
        break;
      case "采购台账":
@@ -1322,6 +1475,9 @@
        specialMapping: { è§„章制度: "规章制度管理" },
      },
      purchase: { specialMapping: { ä¾›åº”商档案: "供应商管理" } },
      // æ‰‹æœºç«¯äººäº‹æ¨¡å—已占用「合同管理」label(走 /staff/staffContract),
      // é”€å”®åˆåŒæ”¹ç”¨ã€Œé”€å”®åˆåŒã€label,此处映射回后端的菜单标题
      marketing: { specialMapping: { é”€å”®åˆåŒ: "合同管理" } },
    };
    console.log(allowedMenuTitles);
    // é€šç”¨è¿‡æ»¤å‡½æ•°
@@ -1338,7 +1494,7 @@
    };
    // è¿‡æ»¤å„个模块
    filterArray(marketingItems);
    filterArray(marketingItems, menuMapping.marketing.specialMapping);
    filterArray(designItems);
    filterArray(purchaseItems, menuMapping.purchase.specialMapping);
    filterArray(financeManagementItems);
@@ -1351,6 +1507,8 @@
    filterArray(equipmentItems);
    filterArray(archiveManagementItems);
    filterArray(afterSalesServiceItems);
    filterArray(projectItems);
    filterArray(vehicleItems);
  };
  // æ£€æŸ¥æ¨¡å—是否有菜单项需要显示
@@ -1375,6 +1533,8 @@
  const hasArchiveManagementItems = computed(
    () => archiveManagementItems.length > 0
  );
  const hasProjectItems = computed(() => projectItems.length > 0);
  const hasVehicleItems = computed(() => vehicleItems.length > 0);
  onMounted(() => {
    // æ¯æ¬¡è¿›å…¥é¦–页都强制刷新用户信息和路由权限,不做本地缓存判断
@@ -1753,6 +1913,14 @@
    --module-color: #9c27b0;
  }
  .project-module {
    --module-color: #0066ff;
  }
  .vehicle-module {
    --module-color: #ff8a3d;
  }
  .module-header {
    margin-bottom: 10px;
    display: flex;
@@ -1999,6 +2167,14 @@
    --module-color: #9c27b0;
  }
  .project-module {
    --module-color: #0066ff;
  }
  .vehicle-module {
    --module-color: #ff8a3d;
  }
  .module-header {
    margin-bottom: 18px;
    padding-left: 18px;
src/static/images/icon/baobiaoguanli.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#19BE6B"/>
  <rect x="8.4" y="14" width="2.8" height="5.4" rx="0.8" fill="#A0E8C0"/>
  <rect x="12.6" y="10.5" width="2.8" height="8.9" rx="0.8" fill="#FFFFFF"/>
  <rect x="16.8" y="12.8" width="2.8" height="6.6" rx="0.8" fill="#A0E8C0"/>
  <rect x="7" y="19.4" width="14" height="1.7" rx="0.85" fill="#FFFFFF"/>
</svg>
src/static/images/icon/cheliangguanli.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#2F7BE0"/>
  <path d="M9.2 13.2V11.6A1.8 1.8 0 0 1 11 9.8h6a1.8 1.8 0 0 1 1.8 1.8v1.6z" fill="#99D9FC"/>
  <rect x="6" y="13.2" width="16" height="5.4" rx="2" fill="#FFFFFF"/>
  <rect x="10.4" y="10.6" width="2.6" height="1.6" rx="0.5" fill="#2F7BE0"/>
  <rect x="15" y="10.6" width="2.6" height="1.6" rx="0.5" fill="#2F7BE0"/>
  <rect x="17.8" y="14.6" width="2.4" height="1.7" rx="0.85" fill="#99D9FC"/>
  <circle cx="9.6" cy="18.6" r="1.9" fill="#FFFFFF"/>
  <circle cx="9.6" cy="18.6" r="0.85" fill="#2F7BE0"/>
  <circle cx="18.4" cy="18.6" r="1.9" fill="#FFFFFF"/>
  <circle cx="18.4" cy="18.6" r="0.85" fill="#2F7BE0"/>
</svg>
src/static/images/icon/fangwuzulin.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#4C9AFF"/>
  <path d="M13.2 5.9a1.2 1.2 0 0 1 1.6 0l8.2 7.2a1.1 1.1 0 0 1-.7 1.9H5.7a1.1 1.1 0 0 1-.7-1.9z" fill="#FFFFFF"/>
  <rect x="7.4" y="14.4" width="13.2" height="8" rx="1.4" fill="#FFFFFF"/>
  <rect x="18.4" y="15.4" width="1.4" height="6" fill="#4C9AFF"/>
  <rect x="8.2" y="15.4" width="1.4" height="6" fill="#4C9AFF"/>
  <rect x="12.4" y="17" width="3.2" height="5.4" rx="0.8" fill="#4C9AFF"/>
  <rect x="16.2" y="16.4" width="2.6" height="2.4" rx="0.6" fill="#BFDBFF"/>
</svg>
src/static/images/icon/jiayoujilu.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#12B7A0"/>
  <rect x="6.5" y="7" width="10.5" height="15" rx="2.2" fill="#FFFFFF"/>
  <rect x="8.8" y="9.4" width="6" height="4.2" rx="1" fill="#A7F0E4"/>
  <rect x="8.8" y="15.2" width="6" height="1.6" rx="0.8" fill="#12B7A0"/>
  <rect x="8.8" y="18" width="4.2" height="1.6" rx="0.8" fill="#12B7A0"/>
  <path d="M20.2 9.4h1.8a1.8 1.8 0 0 1 1.8 1.8v5.6a1.8 1.8 0 1 1-3.6 0" fill="none" stroke="#FFFFFF" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>
src/static/images/icon/jiayoukaguanli.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#FF8A3D"/>
  <rect x="4.6" y="8.4" width="18.8" height="11.6" rx="2.2" fill="#FFFFFF"/>
  <rect x="4.6" y="11.2" width="18.8" height="2.6" fill="#FFD9B3"/>
  <rect x="7.4" y="15.4" width="3.6" height="2.8" rx="0.9" fill="#FFB870"/>
  <rect x="14.4" y="15.4" width="6.6" height="1.5" rx="0.75" fill="#FFD9B3"/>
  <rect x="14.4" y="18" width="4.2" height="1.5" rx="0.75" fill="#FFD9B3"/>
</svg>
src/static/images/icon/kuandaiyutongxin.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#7C5CFC"/>
  <path d="M6.6 12.2a10.4 10.4 0 0 1 14.8 0" fill="none" stroke="#C9BCFF" stroke-width="1.8" stroke-linecap="round"/>
  <path d="M9.9 15.4a5.8 5.8 0 0 1 8.2 0" fill="none" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round"/>
  <circle cx="14" cy="19" r="1.9" fill="#FFFFFF"/>
</svg>
src/static/images/icon/susongguanli.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#E6A23C"/>
  <rect x="13.2" y="8" width="1.6" height="12" rx="0.8" fill="#FFFFFF"/>
  <rect x="6.8" y="10.2" width="14.4" height="1.6" rx="0.8" fill="#FFFFFF"/>
  <rect x="9.6" y="19.4" width="8.8" height="1.7" rx="0.85" fill="#FFFFFF"/>
  <rect x="8" y="11.6" width="1" height="3.2" rx="0.5" fill="#FFE7A3"/>
  <rect x="19" y="11.6" width="1" height="3.2" rx="0.5" fill="#FFE7A3"/>
  <ellipse cx="8.5" cy="15.4" rx="2.6" ry="1.2" fill="#FFE7A3"/>
  <ellipse cx="19.5" cy="15.4" rx="2.6" ry="1.2" fill="#FFE7A3"/>
</svg>
src/static/images/icon/xiangmuguanli.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#0066FF"/>
  <rect x="8" y="8.5" width="12" height="13.5" rx="2.5" fill="#FFFFFF"/>
  <rect x="11" y="6" width="6" height="4" rx="2" fill="#7BF1FF"/>
  <rect x="10.5" y="13" width="7" height="1.6" rx="0.8" fill="#0066FF"/>
  <rect x="10.5" y="16.5" width="4.5" height="1.6" rx="0.8" fill="#0066FF"/>
</svg>
src/static/images/icon/zhaotoubiaoguanli.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
  <rect x="2" y="2" width="24" height="24" rx="7" fill="#3462CC"/>
  <g transform="rotate(-45 14 14)">
    <rect x="8.5" y="7.6" width="11" height="5" rx="1.6" fill="#FFFFFF"/>
    <rect x="12.25" y="12.6" width="3.5" height="8" rx="1.6" fill="#99D9FC"/>
  </g>
  <circle cx="20.4" cy="20.4" r="3.6" fill="#3462CC"/>
  <circle cx="20.4" cy="20.4" r="2.1" fill="#FFFFFF"/>
</svg>
src/utils/delivery.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
/**
 * å‘货台账详情解包
 *
 * åŽç«¯ /shippingInfo/getDateil/{id} çš„产品明细数组所在字段名在不同流程下不一致
 * ï¼ˆå®¡æ‰¹è¯¦æƒ…èµ° shippingProductDetailDtoList,发货台账走 batchNoDetailList ç­‰ï¼‰ï¼Œ
 * è¡Œå†…的批次号/数量也有多套命名。集中在这里探测,页面里不出现裸字段名。
 */
import { toFileList } from "@/utils/file";
const LIST_KEYS = [
  "batchNoDetailList",
  "batchNoList",
  "shippingBatchList",
  "shippingInfoDetailList",
  "shippingProductDetailDtoList",
  "detailList",
  "batchDetailList",
  "rows",
  "records",
  "list",
];
function pickList(source) {
  if (!source) return [];
  for (const key of LIST_KEYS) {
    if (Array.isArray(source[key])) return source[key];
  }
  return [];
}
const pick = (row, keys) => {
  for (const key of keys) {
    const value = row?.[key];
    if (value !== undefined && value !== null && value !== "") return value;
  }
  return "";
};
/**
 * å½’一化产品明细行
 * @returns {Array<{batchNo, productName, specificationModel, deliveryQuantity, unit, remark}>}
 */
export function resolveDeliveryProducts(data) {
  return pickList(data).map(row => ({
    batchNo: pick(row, ["batchNo", "batchNumber"]),
    productName: pick(row, ["productName", "product"]),
    specificationModel: pick(row, ["specificationModel", "model", "specification"]),
    deliveryQuantity: pick(row, ["deliveryQuantity", "quantity", "shippingQuantity"]),
    unit: pick(row, ["unit", "measureUnit"]),
    remark: pick(row, ["remark", "remarks"]),
  }));
}
/**
 * å‘货图片回显:deliveryFileList / storageBlobVOs ä¸¤ç§å‘½å
 */
export function resolveDeliveryFiles(data) {
  return toFileList(
    data?.deliveryFileList || data?.storageBlobVOs || data?.fileList
  );
}
/**
 * é€€è´§å•号列表:兼容字符串数组与 [{returnNo}] ä¸¤ç§ç»“æž„
 */
export function resolveReturnNos(source) {
  if (!Array.isArray(source)) return [];
  return source
    .map(item =>
      typeof item === "string" || typeof item === "number"
        ? String(item)
        : item?.returnNo || item?.returnOrderNo || ""
    )
    .filter(Boolean);
}
src/utils/detailCache.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
/**
 * è¯¦æƒ…数据加载:接口优先,缓存兜底
 *
 * åˆ—表页跳转时会 `uni.setStorageSync('xxxDetail', row)`,但列表行只有列表列字段,
 * é™„件、子表、归档情况这些只有详情接口才有。若编辑页先读缓存就回填表单,
 * æäº¤æ—¶ä¼šæŠŠç¼ºå¤±çš„附件一并清空(数据丢失)。
 *
 * æ‰€ä»¥ç»Ÿä¸€èµ°ã€Œå…ˆè¯·æ±‚详情接口,失败时才用缓存兜底」,缓存只用于接口不可用时的降级。
 */
/**
 * @param {object}   options
 * @param {string}   options.cacheKey åˆ—表页写入的 storage key
 * @param {string}   options.id       å½“前记录 id
 * @param {Function} options.fetcher  æŽ¥æ”¶ id、返回 Promise çš„详情接口
 * @returns {Promise<object|null>} è¯¦æƒ…对象,取不到返回 null
 */
export async function loadDetailData({ cacheKey, id, fetcher }) {
  const cached = cacheKey ? uni.getStorageSync(cacheKey) : null;
  const fallback =
    cached && String(cached.id) === String(id) ? cached : null;
  try {
    const res = await fetcher(id);
    return res?.data || fallback || null;
  } catch (error) {
    if (fallback) return fallback;
    throw error;
  }
}
src/utils/file.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,156 @@
/**
 * é™„件字段兼容工具
 *
 * åŽç«¯å„模块的附件字段名与形态不统一:
 *   å¯¹è±¡æ•°ç»„:storageBlobVOs(回显) / attachmentList / fileList,项含 url||previewURL||fileUrl||path
 *   é€—号字符串:attachmentUrls / attachmentIds
 * é¡µé¢ç»Ÿä¸€è½¬æˆ [{url}] äº¤ç»™ CommonUpload,提交时再按各模块约定转回。
 */
import config from "@/config";
import { getToken } from "@/utils/auth";
/**
 * ä»»æ„å½¢æ€çš„附件数据 â†’ å¯¹è±¡æ•°ç»„(每项保证有可渲染的 url)
 */
export function toFileList(source) {
  if (!source) return [];
  if (Array.isArray(source)) {
    return source
      .map(item => {
        if (typeof item === "string") return item ? { url: item } : null;
        const url = item?.url || item?.previewURL || item?.fileUrl || item?.path;
        return url ? { ...item, url } : null;
      })
      .filter(Boolean);
  }
  if (typeof source === "string") {
    return source
      .split(",")
      .map(part => part.trim())
      .filter(Boolean)
      .map(url => ({ url }));
  }
  return [];
}
/**
 * å¯¹è±¡æ•°ç»„ / å­—符串数组 â†’ é€—号分隔字符串(attachmentUrls è¿™ç±»åŽç«¯å­—段要的形态)
 */
export function toUrlString(fileList) {
  return (fileList || [])
    .map(item =>
      typeof item === "string" ? item : item?.url || item?.previewURL || ""
    )
    .filter(Boolean)
    .join(",");
}
/**
 * é™„ä»¶ url â†’ å¯ä¸‹è½½çš„完整地址
 * é™„件接口回的常是 /profile/upload/... è¿™ç±»åŽç«¯ç›¸å¯¹è·¯å¾„,直接丢给 uni.downloadFile ä¼šå¤±è´¥ï¼Œ
 * æŒ‰ç®¡ç†ç«¯ getRealUrl çš„做法补上 baseUrl。
 */
export function resolveFileUrl(url) {
  const target = String(url ?? "").trim();
  if (!target) return "";
  if (/^https?:\/\//i.test(target)) return target;
  return `${config.baseUrl}${target.startsWith("/") ? "" : "/"}${target}`;
}
/**
 * æ‰“开附件:request.ts æœªé€ä¼  responseType,blob ä¸‹è½½ä¸å¯ç”¨ï¼Œ
 * åªèƒ½ downloadFile åˆ°ä¸´æ—¶æ–‡ä»¶å† openDocument。
 */
export function openFile(url) {
  const target = resolveFileUrl(url);
  if (!target) {
    uni.showToast({ title: "文件地址无效", icon: "none" });
    return;
  }
  uni.showLoading({ title: "打开中...", mask: true });
  uni.downloadFile({
    url: target,
    header: { Authorization: "Bearer " + getToken() },
    success: res => {
      if (res.statusCode !== 200) {
        uni.showToast({ title: "文件下载失败", icon: "none" });
        return;
      }
      uni.openDocument({
        filePath: res.tempFilePath,
        showMenu: true,
        fail: () => uni.showToast({ title: "暂不支持预览该文件", icon: "none" }),
      });
    },
    fail: () => uni.showToast({ title: "文件下载失败", icon: "none" }),
    complete: () => uni.hideLoading(),
  });
}
/**
 * åŽç«¯ç”Ÿæˆæ–‡ä»¶ï¼ˆPDF/Word)并下载打开。
 *
 * è¿™ç±»æŽ¥å£æ˜¯ POST + body,而 uni.downloadFile åªæ”¯æŒ GET,所以只能先用
 * uni.request æ‹¿ arraybuffer,再落到 USER_DATA_PATH åŽ openDocument。
 * uni.getFileSystemManager åœ¨ H5 ä¸å­˜åœ¨ï¼ŒH5 ä¸‹ä¼šæç¤ºä¸æ”¯æŒã€‚
 *
 * @param {string} path    æŽ¥å£è·¯å¾„,如 /quality/qualityInspect/downPdf
 * @param {object} data    è¯·æ±‚体
 * @param {string} fileName è½ç›˜æ–‡ä»¶åï¼ˆå«åŽç¼€ï¼‰
 */
export function downloadFileByPost(path, data = {}, fileName = "download.pdf") {
  uni.showLoading({ title: "生成中...", mask: true });
  return new Promise((resolve, reject) => {
    uni.request({
      url: `${config.baseUrl}${path}`,
      method: "POST",
      data,
      responseType: "arraybuffer",
      header: {
        Authorization: "Bearer " + getToken(),
        "Content-Type": "application/json",
      },
      success: res => {
        const contentType = String(
          res.header?.["Content-Type"] || res.header?.["content-type"] || ""
        );
        // åŽç«¯å‡ºé”™æ—¶å›žçš„æ˜¯ JSON(code/msg),照原样写成 pdf ä¼šå¾—到一个打不开的文件
        if (res.statusCode !== 200 || contentType.includes("application/json")) {
          uni.showToast({ title: "生成文件失败", icon: "none" });
          reject(new Error("生成文件失败"));
          return;
        }
        const fs = uni.getFileSystemManager && uni.getFileSystemManager();
        if (!fs) {
          uni.showToast({ title: "当前环境不支持下载文件", icon: "none" });
          reject(new Error("unsupported"));
          return;
        }
        const filePath = `${uni.env.USER_DATA_PATH}/${fileName}`;
        fs.writeFile({
          filePath,
          data: res.data,
          encoding: "binary",
          success: () => {
            uni.openDocument({
              filePath,
              showMenu: true,
              fail: () =>
                uni.showToast({ title: "暂不支持预览该文件", icon: "none" }),
            });
            resolve(filePath);
          },
          fail: () => {
            uni.showToast({ title: "文件保存失败", icon: "none" });
            reject(new Error("文件保存失败"));
          },
        });
      },
      fail: () => {
        uni.showToast({ title: "生成文件失败", icon: "none" });
        reject(new Error("生成文件失败"));
      },
      complete: () => uni.hideLoading(),
    });
  });
}
src/utils/list.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
/**
 * åˆ—表响应解包工具
 *
 * åŽç«¯å„ controller çš„分页返回结构不统一,实测至少有这几种:
 *   { code, data: { records, total } }
 *   { code, data: [ ... ] }              ï¼ˆçº¯æ•°ç»„)
 *   { code, records, total }             ï¼ˆruoyi é£Žæ ¼ï¼Œrecords åœ¨å¤–层)
 *   { code, rows, total }                ï¼ˆruoyi é£Žæ ¼ï¼‰
 * é›†ä¸­åœ¨è¿™é‡Œå…œåº•,避免每个页面各写一遍 `res?.data?.records || res?.records || []`。
 */
function pickArray(source) {
  if (!source) return null;
  if (Array.isArray(source)) return source;
  const candidates = ["records", "list", "rows", "data", "content", "items"];
  for (const key of candidates) {
    if (Array.isArray(source[key])) return source[key];
  }
  return null;
}
/**
 * ä»Žä»»æ„ç»“构的分页响应里取出列表数组,取不到返回 []
 */
export function unwrapList(res) {
  if (!res) return [];
  return pickArray(res.data) || pickArray(res) || [];
}
/**
 * å–出总条数,取不到返回 0
 */
export function unwrapTotal(res) {
  if (!res) return 0;
  const outer = Number(res.total);
  if (Number.isFinite(outer) && outer > 0) return outer;
  const inner = Number(res.data?.total);
  if (Number.isFinite(inner) && inner > 0) return inner;
  return 0;
}
/**
 * å–出单条详情,取不到返回 null
 */
export function unwrapData(res) {
  if (!res) return null;
  return res.data ?? null;
}
src/utils/paymentCycle.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
/**
 * ç¼´è´¹å‘¨æœŸä¸Žç¼´è´¹æ–¹å¼é€‰é¡¹
 *
 * å®½å¸¦ä¸Žé€šä¿¡ç®¡ç†ã€æˆ¿å±‹ç§Ÿèµç®¡ç†å…±ç”¨åŒä¸€å¥—字典:
 *   ç¼´è´¹å‘¨æœŸ paymentCycle â€”— å­˜ Number(1 æœˆä»˜ / 3 å­£ä»˜ / 6 åŠå¹´ä»˜ / 12 å¹´ä»˜ï¼‰ï¼ŒåŽç«¯ä¹Ÿç”¨å®ƒæŽ¨ç®—下次缴费日期
 *   ç¼´è´¹æ–¹å¼ paymentMethod â€”— å­˜ä¸­æ–‡å­—符串(现金 / é“¶è¡Œè½¬è´¦ / æ”¯ä»˜å® / å¾®ä¿¡ï¼‰
 */
export const PAYMENT_CYCLE_OPTIONS = [
  { name: "月付", value: 1 },
  { name: "季付", value: 3 },
  { name: "半年付", value: 6 },
  { name: "年付", value: 12 },
];
export const PAYMENT_METHOD_OPTIONS = [
  { name: "现金", value: "现金" },
  { name: "银行转账", value: "银行转账" },
  { name: "支付宝", value: "支付宝" },
  { name: "微信", value: "微信" },
];
const CYCLE_TEXT = {
  1: "月付",
  3: "季付",
  6: "半年付",
  12: "年付",
};
export function cycleText(cycle) {
  if (cycle === null || cycle === undefined || cycle === "") return "-";
  return CYCLE_TEXT[Number(cycle)] || "-";
}
src/utils/prefill.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
/**
 * è·¨æ¨¡å—「发起合同录入」的预填参数
 *
 * æ‹›æŠ•标中标后(需求三.6)需要带着项目信息跳到销售台账新增页。
 * æ‰‹æœºç«¯é”€å”®å°è´¦æ–°å¢žèµ° pages/sales/salesAccount/detail.vue,
 * è¯¥é¡µåœ¨ onMounted è¯»å–本 key å®Œæˆé¡¹ç›®/客户回填。
 * é¡¹ç›®å­—段名与项目管理后端保持一致(title=项目名称、clientId/clientName=客户),
 * åœ¨ setPrefillProject é‡Œç»Ÿä¸€å½’一化,调用方传原始行即可。
 */
export const PREFILL_PROJECT_KEY = "prefillProject";
export function setPrefillProject(project) {
  uni.setStorageSync(PREFILL_PROJECT_KEY, {
    projectId: project?.projectId ?? project?.id ?? "",
    projectName: project?.projectName ?? project?.title ?? "",
    customerId: project?.customerId ?? project?.clientId ?? "",
    customerName: project?.customerName ?? project?.clientName ?? "",
  });
}
export function takePrefillProject() {
  const data = uni.getStorageSync(PREFILL_PROJECT_KEY);
  uni.removeStorageSync(PREFILL_PROJECT_KEY);
  return data || null;
}
src/utils/projectOptions.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
/**
 * é¡¹ç›®ä¸‹æ‹‰é€‰é¡¹
 *
 * æ‹›æŠ•标、诉讼、销售报价、销售台账等多处需要「选择所属项目」,
 * åŽç«¯é¡¹ç›®ç®¡ç†è¿”回的字段名不统一(项目名称在 title ä¸Šï¼Œid åœ¨ id ä¸Šï¼‰ï¼Œ
 * ç»Ÿä¸€åœ¨è¿™é‡Œå½’一化成 FormPicker è®¤è¯†çš„ {name, value},并保留 raw ä¾›å›žå¡«ä½¿ç”¨ã€‚
 */
import { listProject } from "@/api/projectManagement/project";
const pickProjectName = project =>
  project?.title ?? project?.projectName ?? project?.name ?? "";
export function normalizeProjectOptions(records = []) {
  return (records || []).map(project => ({
    name: pickProjectName(project),
    value: project.id ?? project.projectId,
    raw: project,
  }));
}
/**
 * æ‹‰å–项目列表(不分页)
 * @returns {Promise<Array<{name, value, raw}>>}
 */
export async function loadProjectOptions(params = {}) {
  const res = await listProject({ current: -1, size: -1, ...params });
  const records = res?.data?.records || res?.records || res?.data || [];
  return normalizeProjectOptions(Array.isArray(records) ? records : []);
}
export { pickProjectName };
src/utils/warning.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,137 @@
/**
 * åˆ°æœŸé¢„警计算工具
 *
 * å„业务模块的预警阈值不同:
 *   è½¦è¾†å¹´æ£€/保险、房屋租赁合同、设备校准、报表有效期 â†’ 30 å¤©
 *   å®½å¸¦ä¸Žé€šä¿¡ä¸‹æ¬¡ç¼´è´¹ â†’ 15 å¤©
 *   é”€å”®å°è´¦åˆåŒåŽŸä»¶çº¦å®šå›žä¼ æ—¥æœŸ â†’ æå‰ N å¤©ï¼ˆç”±é¡¹ç›®ç®¡ç†é…ç½®å†³å®šï¼‰
 * ç»Ÿä¸€ç”¨ warnDays ä¼ å…¥ï¼Œé¿å…å„页面各写一套日期差逻辑。
 */
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
 * è§£æžåŽç«¯è¿”回的日期,兼容时间戳 / Date / 'YYYY-MM-DD' / 'YYYY/MM/DD' / 'YYYY-MM-DD HH:mm:ss'
 */
export function parseDate(value) {
  if (value === undefined || value === null || value === "") return null;
  if (value instanceof Date) {
    return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
  }
  if (typeof value === "number") {
    const d = new Date(value);
    return Number.isNaN(d.getTime()) ? null : d;
  }
  const matched = String(value)
    .trim()
    .replace(/\//g, "-")
    .replace("T", " ")
    .match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  if (!matched) return null;
  const date = new Date(
    Number(matched[1]),
    Number(matched[2]) - 1,
    Number(matched[3])
  );
  return Number.isNaN(date.getTime()) ? null : date;
}
/**
 * è·ç¦»åˆ°æœŸæ—¥çš„剩余天数
 * æ­£æ•°=还有 N å¤©ï¼Œ0=今天到期,负数=已逾期 N å¤©ï¼Œæ— æ³•解析返回 null
 */
export function remainDays(dateStr) {
  const target = parseDate(dateStr);
  if (!target) return null;
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  target.setHours(0, 0, 0, 0);
  return Math.round((target.getTime() - today.getTime()) / MS_PER_DAY);
}
/**
 * é¢„警级别:overdue å·²é€¾æœŸ / warning å³å°†åˆ°æœŸ / normal æ­£å¸¸ / none æ— æ—¥æœŸ
 */
export function warnLevel(dateStr, warnDays = 30) {
  const days = remainDays(dateStr);
  if (days === null) return "none";
  if (days < 0) return "overdue";
  if (days <= warnDays) return "warning";
  return "normal";
}
export function warnColor(level) {
  const colors = {
    overdue: "#f56c6c",
    warning: "#ff9900",
    normal: "#19be6b",
    none: "#909399",
  };
  return colors[level] || colors.none;
}
export function warnText(level, days) {
  if (level === "overdue") return `已逾期${Math.abs(days)}天`;
  if (level === "warning") return days === 0 ? "今天到期" : `即将到期(${days}天)`;
  if (level === "normal") return "正常";
  return "";
}
/**
 * ä¸€æ¬¡æ‹¿åˆ°æ¸²æŸ“预警标记所需的全部信息
 * @returns {{days: number|null, level: string, color: string, text: string}}
 */
export function getWarnInfo(dateStr, warnDays = 30) {
  const days = remainDays(dateStr);
  const level = warnLevel(dateStr, warnDays);
  return {
    days,
    level,
    color: warnColor(level),
    text: warnText(level, days),
  };
}
/**
 * æ˜¯å¦å·²é€¾æœŸæˆ–即将到期(用于列表筛选、高亮)
 */
export function isExpiringSoon(dateStr, warnDays = 30) {
  const level = warnLevel(dateStr, warnDays);
  return level === "overdue" || level === "warning";
}
/**
 * æŒ‰å¤©/月推算日期,用于"下次缴费日期 = å®‰è£…日期 + ç¼´è´¹å‘¨æœŸæœˆ"这类自动计算
 * @returns {string} 'YYYY-MM-DD',无法计算返回 ''
 */
export function addMonths(dateStr, months) {
  const base = parseDate(dateStr);
  if (!base || !Number.isFinite(Number(months))) return "";
  const day = base.getDate();
  base.setDate(1);
  base.setMonth(base.getMonth() + Number(months));
  // å¤„理 1/31 + 1 æœˆè¿™ç±»æº¢å‡ºï¼Œå›žé€€åˆ°å½“月最后一天
  const lastDay = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate();
  base.setDate(Math.min(day, lastDay));
  const pad = n => String(n).padStart(2, "0");
  return `${base.getFullYear()}-${pad(base.getMonth() + 1)}-${pad(base.getDate())}`;
}
/**
 * ç”Ÿæˆ [start, end] ä¹‹é—´çš„æœˆä»½åˆ—表(含首尾),用于房屋租赁的月度费用子表
 * @returns {string[]} ['2026-01', '2026-02', ...]
 */
export function monthRange(startStr, endStr) {
  const start = parseDate(startStr);
  const end = parseDate(endStr);
  if (!start || !end || end < start) return [];
  const months = [];
  const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
  const last = new Date(end.getFullYear(), end.getMonth(), 1);
  while (cursor <= last && months.length < 600) {
    const pad = n => String(n).padStart(2, "0");
    months.push(`${cursor.getFullYear()}-${pad(cursor.getMonth() + 1)}`);
    cursor.setMonth(cursor.getMonth() + 1);
  }
  return months;
}