Kaynağa Gözat

feat: 对齐 SOP 与任务发布表单,新增字典选项及小程序操作说明书

mcc 1 gün önce
ebeveyn
işleme
26bab03a4b
54 değiştirilmiş dosya ile 5929 ekleme ve 3047 silme
  1. 376 0
      docs/manual-generator.js
  2. BIN
      docs/清道夫系统小程序操作说明书.docx
  3. 16 0
      src/App.vue
  4. 4 4
      src/api/auth.ts
  5. 60 0
      src/api/dict.ts
  6. 8 11
      src/api/project.ts
  7. 11 0
      src/api/staff.ts
  8. 28 12
      src/api/task.ts
  9. 18 1
      src/api/team.ts
  10. 163 0
      src/components/common/AppTabBar.vue
  11. 5 1
      src/components/common/ImageUploader.vue
  12. 2 1
      src/components/common/PhotoUploader.vue
  13. 15 6
      src/components/common/StatusTag.vue
  14. 5 1
      src/components/common/TopBar.vue
  15. 338 118
      src/components/dispatch/ScheduleView.vue
  16. 0 2
      src/components/project/ProjectCard.vue
  17. 267 112
      src/components/task/ConstructionTaskCard.vue
  18. 156 80
      src/components/task/DispatchTaskCard.vue
  19. 245 58
      src/components/task/SalesTaskCard.vue
  20. 61 0
      src/composables/useDictOptions.ts
  21. 550 0
      src/composables/usePublishTaskForm.ts
  22. 3 1
      src/config/index.ts
  23. 230 60
      src/constants/sop.ts
  24. 1 1
      src/custom-tab-bar/index.js
  25. 7 5
      src/manifest.json
  26. 12 4
      src/pages.json
  27. 13 1
      src/pages/home/home.vue
  28. 13 1
      src/pages/my/my.vue
  29. 15 1
      src/pages/schedule/schedule.vue
  30. 13 1
      src/pages/task/task.vue
  31. 0 0
      src/static/tabbar/contract.png
  32. 0 0
      src/static/tabbar/customer.png
  33. 0 0
      src/static/tabbar/project.png
  34. 0 0
      src/static/tabbar/schedule-active.png
  35. 0 0
      src/static/tabbar/schedule.png
  36. 12 15
      src/stores/auth.ts
  37. 42 0
      src/stores/dict.ts
  38. 57 2
      src/stores/vehicle.ts
  39. 814 355
      src/subPackages/pages-common/addProject.vue
  40. 198 78
      src/subPackages/pages-common/projectDetail.vue
  41. 55 29
      src/subPackages/pages-common/projectList.vue
  42. 167 592
      src/subPackages/pages-common/publishTask.vue
  43. 625 197
      src/subPackages/pages-common/taskDetail.vue
  44. 265 110
      src/subPackages/pages-construction/dailyTask.vue
  45. 405 492
      src/subPackages/pages-construction/taskDetail.vue
  46. 4 5
      src/subPackages/pages-dispatch/availableVehicles.vue
  47. 1 1
      src/subPackages/pages-dispatch/emergencyStandby.vue
  48. 1 1
      src/subPackages/pages-dispatch/emergencyTeam.vue
  49. 1 1
      src/subPackages/pages-dispatch/notification.vue
  50. 167 592
      src/subPackages/pages-dispatch/publishTask.vue
  51. 200 59
      src/subPackages/pages-dispatch/taskDetail.vue
  52. 5 7
      src/subPackages/pages-dispatch/vehicleList.vue
  53. 56 18
      src/types/index.ts
  54. 219 11
      src/utils/index.ts

+ 376 - 0
docs/manual-generator.js

@@ -0,0 +1,376 @@
+const fs = require('fs');
+const path = require('path');
+const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
+        Header, Footer, AlignmentType, PageOrientation, LevelFormat,
+        HeadingLevel, BorderStyle, WidthType, ShadingType, PageNumber, PageBreak } = require('docx');
+
+const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
+const borders = { top: border, bottom: border, left: border, right: border };
+
+function cell(text, width, options = {}) {
+  return new TableCell({
+    borders,
+    width: { size: width, type: WidthType.DXA },
+    shading: options.shading ? { fill: options.shading, type: ShadingType.CLEAR } : undefined,
+    margins: { top: 80, bottom: 80, left: 120, right: 120 },
+    verticalAlign: options.center ? "center" : undefined,
+    children: [new Paragraph({
+      alignment: options.center ? AlignmentType.CENTER : undefined,
+      children: [new TextRun({ text, bold: options.bold, size: 21 })]
+    })]
+  });
+}
+
+function h1(text) {
+  return new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun(text)] });
+}
+
+function h2(text) {
+  return new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun(text)] });
+}
+
+function h3(text) {
+  return new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun(text)] });
+}
+
+function p(text, options = {}) {
+  return new Paragraph({
+    spacing: options.spacing ? { before: options.spacing[0], after: options.spacing[1] } : undefined,
+    children: [new TextRun({ text, size: 21, bold: options.bold })]
+  });
+}
+
+function bullet(text, ref) {
+  return new Paragraph({
+    numbering: { reference: ref, level: 0 },
+    children: [new TextRun({ text, size: 21 })]
+  });
+}
+
+const doc = new Document({
+  styles: {
+    default: { document: { run: { font: "Microsoft YaHei", size: 21 } } },
+    paragraphStyles: [
+      { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
+        run: { size: 36, bold: true, font: "Microsoft YaHei", color: "1F4E78" },
+        paragraph: { spacing: { before: 400, after: 240 }, outlineLevel: 0 } },
+      { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
+        run: { size: 28, bold: true, font: "Microsoft YaHei", color: "2E75B6" },
+        paragraph: { spacing: { before: 320, after: 160 }, outlineLevel: 1 } },
+      { id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true,
+        run: { size: 24, bold: true, font: "Microsoft YaHei" },
+        paragraph: { spacing: { before: 240, after: 120 }, outlineLevel: 2 } },
+    ]
+  },
+  numbering: {
+    config: [
+      { reference: "bullets",
+        levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT,
+          style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
+      { reference: "numbers",
+        levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT,
+          style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
+    ]
+  },
+  sections: [{
+    properties: {
+      page: {
+        size: { width: 12240, height: 15840 },
+        margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }
+      }
+    },
+    headers: {
+      default: new Header({ children: [new Paragraph({
+        alignment: AlignmentType.RIGHT,
+        children: [new TextRun({ text: "清道夫系统操作说明书", size: 18, color: "888888" })]
+      })] })
+    },
+    footers: {
+      default: new Footer({ children: [new Paragraph({
+        alignment: AlignmentType.CENTER,
+        children: [
+          new TextRun({ text: "第 ", size: 18 }),
+          new TextRun({ children: [PageNumber.CURRENT], size: 18 }),
+          new TextRun({ text: " 页", size: 18 })
+        ]
+      })] })
+    },
+    children: [
+      new Paragraph({
+        alignment: AlignmentType.CENTER,
+        spacing: { before: 2400, after: 400 },
+        children: [new TextRun({ text: "清道夫系统", size: 72, bold: true, color: "1F4E78", font: "Microsoft YaHei" })]
+      }),
+      new Paragraph({
+        alignment: AlignmentType.CENTER,
+        spacing: { after: 600 },
+        children: [new TextRun({ text: "小程序端操作说明书", size: 48, color: "2E75B6", font: "Microsoft YaHei" })]
+      }),
+      new Paragraph({
+        alignment: AlignmentType.CENTER,
+        spacing: { before: 1200 },
+        children: [new TextRun({ text: "版本号:V1.0.0", size: 24, font: "Microsoft YaHei" })]
+      }),
+      new Paragraph({
+        alignment: AlignmentType.CENTER,
+        children: [new TextRun({ text: "编制日期:2026年7月", size: 24, font: "Microsoft YaHei" })]
+      }),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("目录"),
+      new Paragraph({
+        spacing: { before: 200, after: 100 },
+        children: [new TextRun({ text: "1. 产品概述", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "2. 登录与账号", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "3. 通用界面说明", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "4. 销售端操作指南", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "5. 调度端操作指南", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "6. 施工端操作指南", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "7. 任务全流程说明", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "8. 通知公告与知识库", size: 22 })]
+      }),
+      new Paragraph({
+        spacing: { after: 100 },
+        children: [new TextRun({ text: "9. 常见问题", size: 22 })]
+      }),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("1. 产品概述"),
+      h2("1.1 应用简介"),
+      p("清道夫系统是一款面向环境服务/市政管道疏通/化粪池清掏等业务场景的移动端作业管理平台。系统通过微信小程序为载体,实现从任务发布、审核调度、现场执行到完工确认的全流程闭环管理。"),
+      h2("1.2 适用对象"),
+      new Table({
+        width: { size: 9360, type: WidthType.DXA },
+        columnWidths: [2340, 2340, 4680],
+        rows: [
+          new TableRow({ children: [cell("角色", 2340, { bold: true, shading: "D5E8F0", center: true }), cell("登录端点", 2340, { bold: true, shading: "D5E8F0", center: true }), cell("核心职责", 4680, { bold: true, shading: "D5E8F0", center: true })] }),
+          new TableRow({ children: [cell("销售/业务员", 2340), cell("mp-sales", 2340), cell("项目库管理、客户管理、合同管理、任务发布、任务跟踪、催单", 4680)] }),
+          new TableRow({ children: [cell("调度员", 2340), cell("mp-dispatch", 2340), cell("任务审核、班组/车辆调度、排班管理、车辆管理、可视化数据、应急管理", 4680)] }),
+          new TableRow({ children: [cell("施工人员/班组", 2340), cell("mp-construction", 2340), cell("接收任务、执行SOP标准流程、每日车辆检查、现场拍照、完工确认", 4680)] }),
+        ]
+      }),
+      h2("1.3 运行环境"),
+      bullet("支持平台:微信小程序(主要)、H5兼容", "bullets"),
+      bullet("前端框架:uni-app + Vue3 + Pinia", "bullets"),
+      bullet("网络要求:需要联网使用,建议使用4G/5G或Wi-Fi", "bullets"),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("2. 登录与账号"),
+      h2("2.1 首次登录"),
+      bullet("打开微信小程序,进入登录页面。", "numbers"),
+      bullet("在账号输入框中输入管理员分配的账号。", "numbers"),
+      bullet("在密码输入框中输入对应密码,可点击眼睛图标切换显示/隐藏。", "numbers"),
+      bullet("如需记住密码,勾选“记住密码”选项。", "numbers"),
+      bullet("点击“登录”按钮。", "numbers"),
+      bullet("系统后端自动识别账号角色,跳转至对应首页。", "numbers"),
+      h2("2.2 角色说明"),
+      p("系统采用后端按账号识别角色的方式,用户无需手动选择角色。登录后根据账号绑定的可登录端点自动进入对应角色首页。不同角色看到的界面、功能菜单和操作权限均不同。"),
+      h2("2.3 退出登录"),
+      bullet("进入“我的”页面。", "bullets"),
+      bullet("点击“退出登录”。", "bullets"),
+      bullet("确认后返回登录页面。", "bullets"),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("3. 通用界面说明"),
+      h2("3.1 底部导航栏"),
+      p("系统采用自定义底部导航栏,根据角色显示不同内容:"),
+      new Table({
+        width: { size: 9360, type: WidthType.DXA },
+        columnWidths: [1560, 2340, 2340, 3120],
+        rows: [
+          new TableRow({ children: [cell("索引", 1560, { bold: true, shading: "D5E8F0", center: true }), cell("页面", 2340, { bold: true, shading: "D5E8F0", center: true }), cell("文字", 2340, { bold: true, shading: "D5E8F0", center: true }), cell("说明", 3120, { bold: true, shading: "D5E8F0", center: true })] }),
+          new TableRow({ children: [cell("0", 1560, { center: true }), cell("pages/home/home", 2340), cell("首页", 2340), cell("所有角色共用,内容按角色区分", 3120)] }),
+          new TableRow({ children: [cell("1", 1560, { center: true }), cell("pages/task/task", 2340), cell("任务", 2340), cell("所有角色共用,列表按角色区分", 3120)] }),
+          new TableRow({ children: [cell("2", 1560, { center: true }), cell("pages/schedule/schedule", 2340), cell("排班", 2340), cell("主要供调度端使用", 3120)] }),
+          new TableRow({ children: [cell("3", 1560, { center: true }), cell("pages/my/my", 2340), cell("我的", 2340), cell("所有角色共用", 3120)] }),
+        ]
+      }),
+      p("注:调度员角色的Tab顺序为首页、排班、任务、我的。"),
+      h2("3.2 通用操作"),
+      bullet("搜索框:在任务、项目、客户、合同等列表页顶部,输入关键词可快速检索。", "bullets"),
+      bullet("筛选器:点击状态下拉框,可按状态筛选列表内容。", "bullets"),
+      bullet("左滑操作:部分列表项支持左滑显示编辑、删除等快捷操作。", "bullets"),
+      bullet("拨打电话:详情页点击联系电话可直接拨号。", "bullets"),
+      bullet("地图定位:任务详情中点击地理位置可唤起导航。", "bullets"),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("4. 销售端操作指南"),
+      h2("4.1 销售端首页"),
+      p("销售登录后进入首页,可查看:"),
+      bullet("统计卡片:本月项目数、待确认任务数、进行中任务数。", "bullets"),
+      bullet("快捷入口:项目库、客户管理、合同管理。", "bullets"),
+      bullet("任务提醒入口与最新任务列表。", "bullets"),
+      bullet("通知公告列表与行业知识列表。", "bullets"),
+      h2("4.2 发布任务"),
+      bullet("进入“任务”页面,点击右上角“+”按钮。", "numbers"),
+      bullet("填写项目基本信息:任务名称、任务类型、选择项目、主联系人姓名、电话。", "numbers"),
+      bullet("填写服务信息:故障位置、地理位置、故障类型、客户原话、故障描述、首次出现时间、近期反复情况、影响范围、故障图片/视频、施工类型。", "numbers"),
+      bullet("填写服务与结算数据:期望到达时间、最晚处理时限、验收标准、结算方式、堵点个数、清淤方量。", "numbers"),
+      bullet("确认工单生成数据:施工指令、风险提示、派单结果判定、施工设施、服务方式、建议出车、取水距离、化粪池排口是否正常。", "numbers"),
+      bullet("填写施工助力信息:预计作业量、预计工时、验收要求、是否有前期施工关联、验收方式、甲方当事人、备注。", "numbers"),
+      bullet("点击提交,任务状态变为“待审核”。", "numbers"),
+      h2("4.3 任务跟踪与催单"),
+      bullet("在“任务”列表中查看已发布任务的状态。", "bullets"),
+      bullet("点击任务卡片进入任务详情。", "bullets"),
+      bullet("点击“催单”按钮,填写催单原因后提交。", "bullets"),
+      h2("4.4 项目库、客户、合同管理"),
+      bullet("项目库:查看、新增、编辑、删除项目信息,支持按合作类型筛选。", "bullets"),
+      bullet("客户管理:维护客户资料,支持按状态筛选。", "bullets"),
+      bullet("合同管理:维护合同信息,支持按状态筛选。", "bullets"),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("5. 调度端操作指南"),
+      h2("5.1 调度端首页"),
+      p("调度员登录后进入首页,可查看:"),
+      bullet("统计卡片:今日任务总数、待排班数量、应急中任务数量。", "bullets"),
+      bullet("快捷入口:排班管理、任务列表、车辆管理、可视化。", "bullets"),
+      bullet("待审核任务列表与消息通知铃铛(显示未读数)。", "bullets"),
+      h2("5.2 审核任务"),
+      bullet("在首页或任务列表查看“待审核”任务。", "numbers"),
+      bullet("点击任务进入详情页。", "numbers"),
+      bullet("查看任务基本信息、项目信息、故障详情、施工信息等。", "numbers"),
+      bullet("点击“审核通过”,任务进入待安排状态;点击“驳回”,填写驳回原因。", "numbers"),
+      h2("5.3 安排班组与车辆"),
+      bullet("审核通过后,在任务详情中点击“安排”。", "numbers"),
+      bullet("选择执行班组。", "numbers"),
+      bullet("选择调度车辆(可多选)。", "numbers"),
+      bullet("确认后任务状态变为“已安排/待确认”。", "numbers"),
+      h2("5.4 排班管理"),
+      bullet("进入底部“排班”Tab。", "numbers"),
+      bullet("使用左右箭头切换月份,点击“今天”快速回到当前日期。", "numbers"),
+      bullet("点击“新增排班”。", "numbers"),
+      bullet("选择班组、工作日期、排班类型(月计划/应急)、车辆,填写备注。", "numbers"),
+      bullet("确认保存后,日历上对应日期显示排班标记。", "numbers"),
+      h2("5.5 车辆管理"),
+      bullet("通过首页快捷入口进入车辆列表。", "bullets"),
+      bullet("可按状态筛选:全部、可用、使用中、维修中。", "bullets"),
+      bullet("点击车辆卡片查看车辆详情弹窗,包含车牌号、类型、尺寸、载重、容积、驾驶员、电话、购车日期、里程、状态等。", "bullets"),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("6. 施工端操作指南"),
+      h2("6.1 施工端首页"),
+      p("施工人员登录后进入首页,可查看:"),
+      bullet("统计卡片:已完成任务数、进行中任务数、明日任务数。", "bullets"),
+      bullet("快捷入口:我的任务、车辆检查、帮助中心。", "bullets"),
+      bullet("每日任务入口(带红点提醒)、当前任务列表、通知公告列表。", "bullets"),
+      h2("6.2 接收与确认任务"),
+      bullet("在首页或任务列表查看分配的任务。", "numbers"),
+      bullet("点击任务进入任务详情。", "numbers"),
+      bullet("在“确认任务”步骤选择:确认任务、反馈问题或拒绝任务。", "numbers"),
+      h2("6.3 SOP八步执行流程"),
+      p("施工端任务详情内置标准SOP流程,共8步:"),
+      new Table({
+        width: { size: 9360, type: WidthType.DXA },
+        columnWidths: [1200, 2160, 6000],
+        rows: [
+          new TableRow({ children: [cell("步骤", 1200, { bold: true, shading: "D5E8F0", center: true }), cell("节点", 2160, { bold: true, shading: "D5E8F0", center: true }), cell("操作说明", 6000, { bold: true, shading: "D5E8F0", center: true })] }),
+          new TableRow({ children: [cell("1", 1200, { center: true }), cell("确认任务", 2160), cell("确认/反馈问题/拒绝任务", 6000)] }),
+          new TableRow({ children: [cell("2", 1200, { center: true }), cell("出车准备", 2160), cell("完成车辆点检:机油、轮胎、灯光、水管、工具", 6000)] }),
+          new TableRow({ children: [cell("3", 1200, { center: true }), cell("到达现场", 2160), cell("现场踏勘:对接人是否到位、现场是否一致、客户是否同意施工", 6000)] }),
+          new TableRow({ children: [cell("4", 1200, { center: true }), cell("开始施工", 2160), cell("填写堵塞物情况描述", 6000)] }),
+          new TableRow({ children: [cell("5", 1200, { center: true }), cell("施工完成", 2160), cell("填写设施反馈描述", 6000)] }),
+          new TableRow({ children: [cell("6", 1200, { center: true }), cell("现场清洗", 2160), cell("推进流程,可上传照片", 6000)] }),
+          new TableRow({ children: [cell("7", 1200, { center: true }), cell("验收签字", 2160), cell("推进流程,可上传照片", 6000)] }),
+          new TableRow({ children: [cell("8", 1200, { center: true }), cell("完工确认", 2160), cell("完成任务,状态变为已完成", 6000)] }),
+        ]
+      }),
+      p("每步均可上传现场照片(最多6张)和填写备注。"),
+      h2("6.4 每日车辆检查"),
+      bullet("通过首页“车辆检查”或“每日任务”入口进入。", "numbers"),
+      bullet("查看今日排班信息和分配车辆。", "numbers"),
+      bullet("选择检查车辆。", "numbers"),
+      bullet("逐项完成常规检查(7项)、设备检查(6项)、三大系统检查(GPS/视频监控/对讲系统)。", "numbers"),
+      bullet("填写备注,点击提交。", "numbers"),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("7. 任务全流程说明"),
+      h2("7.1 日常维保任务流程"),
+      bullet("销售根据合同项目发起定期清掏/疏通任务。", "numbers"),
+      bullet("任务提交后状态为“待审核”。", "numbers"),
+      bullet("调度员审核任务,通过后安排班组和车辆。", "numbers"),
+      bullet("施工班组接收任务并按SOP八步流程执行。", "numbers"),
+      bullet("施工完成后进行验收签字与完工确认。", "numbers"),
+      h2("7.2 应急抢修任务流程"),
+      bullet("销售或调度发起应急任务。", "numbers"),
+      bullet("调度快速安排应急班组和车辆。", "numbers"),
+      bullet("施工人员立即出车,按简化流程或完整SOP执行。", "numbers"),
+      bullet("现场施工完成后进行验收与完工确认。", "numbers"),
+      h2("7.3 状态流转说明"),
+      new Table({
+        width: { size: 9360, type: WidthType.DXA },
+        columnWidths: [3120, 6240],
+        rows: [
+          new TableRow({ children: [cell("状态", 3120, { bold: true, shading: "D5E8F0", center: true }), cell("含义", 6240, { bold: true, shading: "D5E8F0", center: true })] }),
+          new TableRow({ children: [cell("待审核", 3120), cell("销售提交任务后等待调度审核", 6240)] }),
+          new TableRow({ children: [cell("已驳回", 3120), cell("调度审核未通过,需销售修改后重新提交", 6240)] }),
+          new TableRow({ children: [cell("已安排/待确认", 3120), cell("调度已安排班组车辆,等待施工确认", 6240)] }),
+          new TableRow({ children: [cell("进行中", 3120), cell("施工班组已确认并正在执行SOP流程", 6240)] }),
+          new TableRow({ children: [cell("已完成", 3120), cell("任务全部流程执行完毕", 6240)] }),
+          new TableRow({ children: [cell("已取消", 3120), cell("任务被取消", 6240)] }),
+        ]
+      }),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("8. 通知公告与知识库"),
+      h2("8.1 通知公告"),
+      bullet("入口:首页通知列表或“我的”页面“通知公告”。", "bullets"),
+      bullet("可按类型筛选:全部、系统、应急、任务。", "bullets"),
+      bullet("列表显示标题、发布人、发布时间、类型标签。", "bullets"),
+      bullet("左滑可标记已读,点击进入详情。", "bullets"),
+      h2("8.2 行业知识库"),
+      bullet("入口:首页行业知识列表。", "bullets"),
+      bullet("显示知识标题、摘要、创建时间。", "bullets"),
+      bullet("点击进入知识详情查看完整内容。", "bullets"),
+      h2("8.3 帮助中心"),
+      bullet("入口:首页或“我的”页面“帮助中心”。", "bullets"),
+      bullet("按当前角色展示常见问题FAQ。", "bullets"),
+      bullet("提供客服热线、电子邮箱、服务时间。", "bullets"),
+      new Paragraph({ children: [new PageBreak()] }),
+
+      h1("9. 常见问题"),
+      h2("9.1 登录问题"),
+      bullet("忘记密码:请联系管理员重置密码。", "bullets"),
+      bullet("登录失败:请确认账号密码输入正确,区分大小写。", "bullets"),
+      bullet("角色错误:请确认账号已绑定正确的登录端点。", "bullets"),
+      h2("9.2 任务问题"),
+      bullet("任务提交后无状态:请检查网络连接,或联系调度确认是否收到。", "bullets"),
+      bullet("无法上传图片:请检查相册/相机权限是否已开启。", "bullets"),
+      bullet("定位不准确:请检查手机定位服务是否开启。", "bullets"),
+      h2("9.3 操作提示"),
+      bullet("提交表单前请确认必填项已填写完整。", "bullets"),
+      bullet("施工现场照片建议清晰、真实,便于后续验收和追溯。", "bullets"),
+      bullet("任务执行过程中如遇异常,可通过“反馈问题”或电话联系调度。", "bullets"),
+      h2("9.4 技术支持"),
+      p("如遇到系统故障或其他问题,请联系技术支持团队获取帮助。"),
+    ]
+  }]
+});
+
+const outputDir = path.join(__dirname);
+if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
+
+Packer.toBuffer(doc).then(buffer => {
+  fs.writeFileSync(path.join(outputDir, "清道夫系统小程序操作说明书.docx"), buffer);
+  console.log("文档生成成功:清道夫系统小程序操作说明书.docx");
+});

BIN
docs/清道夫系统小程序操作说明书.docx


+ 16 - 0
src/App.vue

@@ -1,11 +1,20 @@
 <script setup lang="ts">
 import { onLaunch, onShow } from "@dcloudio/uni-app";
 import { useAuthStore } from "./stores/auth";
+import { useDictStore } from "./stores/dict";
+import { safeHideTabBar } from "./utils";
 
 onLaunch(() => {
   console.log("App Launch");
   const authStore = useAuthStore();
   authStore.checkLogin();
+
+  const dictStore = useDictStore();
+  dictStore.loadCommonDicts();
+
+  // #ifdef H5 || APP-PLUS
+  safeHideTabBar({ animation: false })
+  // #endif
 });
 
 onShow(() => {
@@ -15,4 +24,11 @@ onShow(() => {
 
 <style>
 @import "@/styles/uni-icons.css";
+
+/* #ifdef H5 */
+/* 隐藏 H5 原生 tabBar,统一使用自定义 AppTabBar(按角色显示不同 tab) */
+.uni-tabbar {
+  display: none !important;
+}
+/* #endif */
 </style>

+ 4 - 4
src/api/auth.ts

@@ -3,7 +3,7 @@ import { post } from './request'
 export type MpRoleType = 'mp-sales' | 'mp-dispatch' | 'mp-construction'
 
 export interface LoginParams {
-  username: string
+  account: string
   password: string
   roleType?: MpRoleType | 'mp'
 }
@@ -54,12 +54,12 @@ export interface LoginResult {
   needChangePassword?: boolean
   menus?: LoginMenu[]
   permissions?: string[]
-  endpoints?: string[]
+  mpEndpoint?: 'mp-sales' | 'mp-dispatch' | 'mp-construction'
 }
 
 export function login(params: LoginParams) {
-  // 未指定具体端点时传 'mp' 标记,表示小程序端登录(后端据此签发 mp token)
-  return post<LoginResult>('/auth/login', { ...params, roleType: params.roleType ?? 'mp' })
+  // 不默认传 roleType,由后端根据账号配置的 mpEndpoint 判断登录端
+  return post<LoginResult>('/auth/login', params)
 }
 
 export function logout() {

+ 60 - 0
src/api/dict.ts

@@ -0,0 +1,60 @@
+import { get } from './request'
+
+export interface DictData {
+  dictCode?: number
+  dictSort?: number
+  dictLabel: string
+  dictValue: string
+  dictType: string
+  cssClass?: string
+  listClass?: string
+  isDefault?: string
+  status?: string
+  parentId?: number
+  ancestors?: string
+  remark?: string
+  children?: DictData[]
+}
+
+export interface DictOptions {
+  options: { value: string; label: string }[]
+  labelMap: Record<string, string>
+  colorMap: Record<string, string>
+  data: DictData[]
+}
+
+/**
+ * 获取指定字典类型的树形数据
+ * @param dictType 字典类型,如 cooperation_type / facility_type / project_status
+ */
+export function getDictTree(dictType: string): Promise<DictData[]> {
+  return get<DictData[]>(`/dict/data/tree/${dictType}`)
+}
+
+/**
+ * 将字典数据转换为选项/映射结构
+ */
+export function buildDictOptions(data: DictData[]): DictOptions {
+  const flatten = (items: DictData[]): DictData[] => {
+    return items.reduce<DictData[]>((acc, item) => {
+      acc.push(item)
+      if (item.children && item.children.length > 0) {
+        acc.push(...flatten(item.children))
+      }
+      return acc
+    }, [])
+  }
+
+  const flat = flatten(data || [])
+  const options = flat.map((item) => ({ value: item.dictValue, label: item.dictLabel }))
+  const labelMap = flat.reduce<Record<string, string>>((acc, item) => {
+    acc[item.dictValue] = item.dictLabel
+    return acc
+  }, {})
+  const colorMap = flat.reduce<Record<string, string>>((acc, item) => {
+    acc[item.dictValue] = item.listClass || ''
+    return acc
+  }, {})
+
+  return { options, labelMap, colorMap, data }
+}

+ 8 - 11
src/api/project.ts

@@ -2,30 +2,29 @@ import { get, post, put, del } from './request'
 import type {
   CooperationType,
   FacilityType,
-  Industry,
   PageResult,
   PaymentMethod,
   Project,
   ProjectStatus
 } from '../types'
 
-/** 提交给后台的项目字段(对齐 BizProject,facilityTypes/servicePoints 为 JSON 字符串) */
+/** 提交给后台的项目字段(对齐 BizProject,facilityTypes/servicePoints/locationContacts 为 JSON 字符串) */
 export interface ProjectParams {
   projectName: string
   cooperationType: CooperationType
-  contractId: number | string
+  contractId?: number | string
   customerId?: number | string
   customerName?: string
   customerPhone?: string
   contractNo?: string
   facilityTypes?: string
-  industry?: Industry
-  occupancyRate?: number
-  buildingArea?: number
-  contractStartDate?: string
-  contractEndDate?: string
+  industry?: string
+  contractAmount?: number
+  serviceStartDate?: string
+  serviceEndDate?: string
   projectDescription?: string
   servicePoints?: string
+  locationContacts?: string
   address?: string
   province?: string
   city?: string
@@ -36,10 +35,8 @@ export interface ProjectParams {
   longitude?: string
   primaryContactName?: string
   primaryContactPhone?: string
-  backupContactName?: string
-  backupContactPhone?: string
-  specialContactReq?: string
   paymentMethod?: PaymentMethod
+  contractStatus?: string
   status?: ProjectStatus
   remark?: string
 }

+ 11 - 0
src/api/staff.ts

@@ -1,9 +1,20 @@
 import { get, post, put, del } from './request'
 
+export interface SimpleStaff {
+  value: number
+  label: string
+  staffId: number
+  name: string
+}
+
 export function getStaffList(pageNum = 1, pageSize = 10, status?: string) {
   return get('/staff/list', { pageNum, pageSize, status })
 }
 
+export function getStaffSimpleList() {
+  return get<SimpleStaff[]>('/staff/simple-list')
+}
+
 export function getStaffDetail(staffId: number) {
   return get(`/staff/${staffId}`)
 }

+ 28 - 12
src/api/task.ts

@@ -6,13 +6,11 @@ export interface TaskParams {
   projectId: number | string
   taskType: string
   planDate: string
+  planEndDate?: string
   address: string
   status?: string
-  urgencyLevel?: string
-  faultLocation?: string
-  faultType?: string
-  customerWords?: string
   serviceContent?: string
+  faultLocation?: string
   constructionType?: string
   constructionFacility?: string
   serviceMethod?: string
@@ -24,26 +22,40 @@ export interface TaskParams {
   acceptanceMethod?: string
   remark?: string
   extraData?: string
-  // 补齐后台管理端字段
+  // 项目与服务点信息
+  servicePointId?: number
+  servicePointName?: string
+  contacts?: { name: string; phone: string }[]
+  // 结算信息
+  settlementType?: string
+  settlementAmount?: number
+  settlementMethod?: string
+  // 扩展字段
+  expectedArrivalTime?: string
+  outletNormal?: number
+  hasPreviousWork?: number
+  toolRequirement?: string
+  // 兼容/历史字段(发布新任务时不再使用)
+  urgencyLevel?: string
+  faultType?: string
+  customerWords?: string
   firstOccurrenceTime?: string
   recurrenceStatus?: string
   impactScope?: string
-  expectedArrivalTime?: string
   latestDeadline?: string
   acceptanceStandard?: string
-  settlementMethod?: string
   blockageCount?: number
   dredgingVolume?: number
   constructionInstruction?: string
   riskTip?: string
   dispatchResult?: string
-  outletNormal?: number
-  hasPreviousWork?: number
-  toolRequirement?: string
   attentionPoint?: string
-  settlementType?: string
-  settlementAmount?: number
   workOrderNo?: string
+  partyAContacts?: { name: string; phone: string; position?: string }[]
+  // 前端表单别名,提交时映射到对应字段
+  estimatedQuantity?: string
+  estimatedHours?: string
+  acceptanceReq?: string
   [key: string]: any
 }
 
@@ -59,6 +71,10 @@ export function getTaskDetail(taskId: number) {
   return get(`/task/${taskId}`)
 }
 
+export function getTasksByProjectId(projectId: number | string) {
+  return get<any[]>(`/task/project/${projectId}`)
+}
+
 export function createTask(data: TaskParams) {
   return post('/task', data)
 }

+ 18 - 1
src/api/team.ts

@@ -1,10 +1,19 @@
-import { get } from './request'
+import { get, post, put } from './request'
 
 export interface SimpleOption {
   value: number
   label: string
 }
 
+export interface TeamPayload {
+  teamName: string
+  memberIds: string
+  vehicleIds: string
+  teamType: string
+  status: string
+  remark?: string
+}
+
 export function getTeamSimpleList() {
   return get<SimpleOption[]>('/team/simple-list')
 }
@@ -12,3 +21,11 @@ export function getTeamSimpleList() {
 export function getTeamMembers(teamId: number) {
   return get<Array<{ staffId: number; name: string }>>(`/team/${teamId}/members`)
 }
+
+export function createTeam(data: TeamPayload) {
+  return post<number>('/team', data)
+}
+
+export function updateTeam(teamId: number, data: TeamPayload) {
+  return put(`/team`, { ...data, teamId })
+}

+ 163 - 0
src/components/common/AppTabBar.vue

@@ -0,0 +1,163 @@
+<template>
+  <view class="tab-bar">
+    <view
+      v-for="(item, index) in tabs"
+      :key="item.pagePath"
+      class="tab-bar-item"
+      :class="{ active: selected === index }"
+      @click="switchTab(item.pagePath)"
+    >
+      <image
+        v-if="item.icon"
+        class="tab-icon"
+        :src="selected === index ? item.activeIcon : item.icon"
+        mode="aspectFit"
+      />
+      <view v-else class="tab-icon tab-icon-text">{{ item.iconText }}</view>
+      <view class="tab-text">{{ item.text }}</view>
+      <view v-if="selected === index" class="tab-active-bar"></view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted } from 'vue'
+import { useAuthStore } from '@/stores/auth'
+import { safeHideTabBar } from '@/utils'
+
+interface TabItem {
+  pagePath: string
+  text: string
+  icon: string
+  activeIcon: string
+  iconText?: string
+}
+
+const commonTabs: TabItem[] = [
+  { pagePath: 'pages/home/home', text: '首页', icon: '/static/tabbar/home.png', activeIcon: '/static/tabbar/home-active.png' },
+  { pagePath: 'pages/task/task', text: '任务', icon: '/static/tabbar/task.png', activeIcon: '/static/tabbar/task-active.png' },
+  { pagePath: 'pages/my/my', text: '我的', icon: '/static/tabbar/my.png', activeIcon: '/static/tabbar/my-active.png' },
+]
+
+const dispatchTabs: TabItem[] = [
+  { pagePath: 'pages/home/home', text: '首页', icon: '/static/tabbar/home.png', activeIcon: '/static/tabbar/home-active.png' },
+  { pagePath: 'pages/schedule/schedule', text: '排班', icon: '/static/tabbar/schedule.png', activeIcon: '/static/tabbar/schedule-active.png' },
+  { pagePath: 'pages/task/task', text: '任务', icon: '/static/tabbar/task.png', activeIcon: '/static/tabbar/task-active.png' },
+  { pagePath: 'pages/my/my', text: '我的', icon: '/static/tabbar/my.png', activeIcon: '/static/tabbar/my-active.png' },
+]
+
+const constructionTabs: TabItem[] = [
+  { pagePath: 'pages/home/home', text: '首页', icon: '/static/tabbar/home.png', activeIcon: '/static/tabbar/home-active.png' },
+  { pagePath: 'pages/task/task', text: '任务', icon: '/static/tabbar/task.png', activeIcon: '/static/tabbar/task-active.png' },
+  { pagePath: 'pages/my/my', text: '我的', icon: '/static/tabbar/my.png', activeIcon: '/static/tabbar/my-active.png' },
+]
+
+const authStore = useAuthStore()
+const role = computed(() => {
+  const endpointMap: Record<string, 'sales' | 'dispatch' | 'construction'> = {
+    'mp-sales': 'sales',
+    'mp-dispatch': 'dispatch',
+    'mp-construction': 'construction',
+  }
+  return authStore.userRole || endpointMap[authStore.userMpEndpoint || ''] || 'sales'
+})
+
+const tabs = computed<TabItem[]>(() => {
+  if (role.value === 'dispatch') return dispatchTabs
+  if (role.value === 'construction') return constructionTabs
+  return commonTabs
+})
+
+const selected = computed(() => {
+  const pages = getCurrentPages()
+  const route = pages[pages.length - 1]?.route || ''
+  return tabs.value.findIndex((t) => t.pagePath === route)
+})
+
+function switchTab(pagePath: string) {
+  const url = '/' + pagePath
+  // #ifdef APP-PLUS
+  // App 端未配置原生 tabBar,使用 reLaunch 模拟切换
+  uni.reLaunch({ url })
+  // #endif
+  // #ifndef APP-PLUS
+  uni.switchTab({ url })
+  // #endif
+}
+
+onMounted(() => {
+  // H5/App 默认 tabBar 会覆盖自定义组件,强制隐藏
+  // #ifdef H5 || APP-PLUS
+  safeHideTabBar({ animation: false })
+  // #endif
+})
+</script>
+
+<style scoped>
+.tab-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  min-height: 50px;
+  padding-bottom: env(safe-area-inset-bottom);
+  background: rgba(255, 255, 255, 0.9);
+  backdrop-filter: blur(12px);
+  -webkit-backdrop-filter: blur(12px);
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  border-top: 1px solid rgba(164, 216, 152, 0.4);
+  z-index: 9999;
+}
+
+.tab-bar-item {
+  position: relative;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 50px;
+  color: #709484;
+}
+
+.tab-bar-item.active {
+  color: #368f6f;
+}
+
+.tab-icon {
+  width: 24px;
+  height: 24px;
+  margin-bottom: 2px;
+}
+
+.tab-icon-text {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+  line-height: 24px;
+}
+
+.tab-text {
+  font-size: 10px;
+  line-height: 14px;
+  font-weight: 500;
+}
+
+.tab-bar-item.active .tab-text {
+  font-weight: 600;
+}
+
+.tab-active-bar {
+  position: absolute;
+  top: 0;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 22px;
+  height: 3px;
+  border-radius: 2px;
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
+}
+</style>

+ 5 - 1
src/components/common/ImageUploader.vue

@@ -15,6 +15,7 @@
           @click="previewImage(index)"
         />
         <view
+          v-if="!disabled"
           class="absolute top-0 right-0 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center -mt-1 -mr-1 z-10"
           @click="removeImage(index)"
         >
@@ -24,7 +25,7 @@
 
       <!-- 上传按钮 -->
       <view
-        v-if="normalizedList.length < maxCount"
+        v-if="!disabled && normalizedList.length < maxCount"
         class="w-20 h-20 rounded-lg border-2 border-dashed border-gray-300 flex flex-col items-center justify-center cursor-pointer bg-gray-50"
         @click="chooseImage"
       >
@@ -62,6 +63,8 @@ interface Props {
   phase?: string
   /** 删除时是否同步后端 */
   syncDelete?: boolean
+  /** 是否禁用上传与删除 */
+  disabled?: boolean
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -70,6 +73,7 @@ const props = withDefaults(defineProps<Props>(), {
   autoUpload: true,
   type: 'file',
   syncDelete: true,
+  disabled: false,
 })
 
 const emit = defineEmits<{

+ 2 - 1
src/components/common/PhotoUploader.vue

@@ -37,6 +37,7 @@
 
 <script setup lang="ts">
 import { computed, ref } from 'vue'
+import { chooseMediaCompat } from '../../utils'
 import { uploadFile, uploadTaskPhoto } from '../../api/file'
 
 const props = withDefaults(
@@ -79,7 +80,7 @@ function chooseMedia() {
     return
   }
 
-  uni.chooseMedia({
+  chooseMediaCompat({
     count: remainCount,
     mediaType: props.mediaType,
     sourceType: ['album', 'camera'],

+ 15 - 6
src/components/common/StatusTag.vue

@@ -21,6 +21,12 @@ const props = withDefaults(
   defineProps<{
     status: string
     type?: StatusTagType
+    /** 自定义文本,传入后将覆盖 type 对应的默认文本 */
+    label?: string
+    /** 自定义颜色(十六进制),传入后将覆盖 type 对应的默认颜色 */
+    color?: string
+    /** 自定义背景色(十六进制),传入后将覆盖 type 对应的默认背景色 */
+    bgColor?: string
   }>(),
   {
     type: 'task'
@@ -28,17 +34,20 @@ const props = withDefaults(
 )
 
 const label = computed(() => {
+  if (props.label) return props.label
   return props.type === 'project'
     ? getProjectStatusText(props.status)
     : getStatusText(props.status)
 })
 
-const textColor = computed(() =>
-  props.type === 'project' ? getProjectStatusColor(props.status) : getStatusColor(props.status)
-)
-const bgColor = computed(() =>
-  props.type === 'project' ? getProjectStatusBgColor(props.status) : getStatusBgColor(props.status)
-)
+const textColor = computed(() => {
+  if (props.color) return props.color
+  return props.type === 'project' ? getProjectStatusColor(props.status) : getStatusColor(props.status)
+})
+const bgColor = computed(() => {
+  if (props.bgColor) return props.bgColor
+  return props.type === 'project' ? getProjectStatusBgColor(props.status) : getStatusBgColor(props.status)
+})
 </script>
 
 <style scoped>

+ 5 - 1
src/components/common/TopBar.vue

@@ -63,8 +63,12 @@ onMounted(() => {
 })
 
 function goBack() {
-  if (props.showBack) {
+  if (!props.showBack) return
+  const pages = getCurrentPages()
+  if (pages.length > 1) {
     uni.navigateBack({ delta: 1 })
+  } else {
+    uni.switchTab({ url: '/pages/home/home' })
   }
 }
 </script>

+ 338 - 118
src/components/dispatch/ScheduleView.vue

@@ -120,39 +120,41 @@
           <EmptyState message="该日无排班" />
         </view>
 
-        <view v-else>
+        <view v-else class="schedule-list">
           <view
             v-for="schedule in daySchedules"
             :key="schedule.scheduleId"
-            class="list-row"
-            hover-class="row-hover"
+            class="schedule-card"
+            hover-class="schedule-card-hover"
             :hover-start-time="0"
             :hover-stay-time="120"
             @click="viewSchedule(schedule)"
           >
-            <view
-              class="icon-chip mr-3"
-              :class="schedule.shiftTypeNorm === 'emergency' ? 'icon-chip-danger' : ''"
-            >
-              <text
-                class="uni-icons uniui-calendar-filled chip-glyph"
-                :class="schedule.shiftTypeNorm === 'emergency' ? 'chip-glyph-danger' : ''"
-              ></text>
+            <view class="card-header">
+              <view class="shift-badge" :class="getShiftBadgeClass(schedule)">
+                <text class="uni-icons shift-badge__icon" :class="getShiftBadgeIcon(schedule)"></text>
+                <text class="shift-badge__text">{{ schedule.shiftTypeText }}</text>
+              </view>
+              <view class="status-badge" :class="schedule.statusClass">
+                {{ schedule.statusText }}
+              </view>
             </view>
-            <view class="flex-1 min-w-0 mr-2">
-              <view class="flex items-center">
-                <text class="row-title truncate mr-2">{{ schedule.teamName || '未命名班组' }}</text>
-                <text
-                  class="shift-tag flex-shrink-0"
-                  :class="schedule.shiftTypeNorm === 'emergency' ? 'shift-tag-danger' : ''"
-                >
-                  {{ schedule.shiftTypeText }}
-                </text>
+
+            <view class="card-body">
+              <text class="team-name">{{ schedule.teamDisplayName || '未命名班组' }}</text>
+              <view class="vehicle-line">
+                <text class="uni-icons uniui-navigate car-icon"></text>
+                <text class="vehicle-text">{{ schedule.vehicleNamesText || '未分配车辆' }}</text>
               </view>
-              <text class="row-sub truncate">{{ schedule.vehicleNamesText || '未分配车辆' }}</text>
             </view>
-            <text class="status-text mr-2">{{ schedule.statusText }}</text>
-            <text class="uni-icons uniui-arrowright row-arrow"></text>
+
+            <view class="card-footer">
+              <view class="date-line">
+                <text class="uni-icons uniui-calendar date-icon"></text>
+                <text class="date-text">{{ schedule.workDate }}</text>
+              </view>
+              <text class="uni-icons uniui-arrowright row-arrow"></text>
+            </view>
           </view>
         </view>
       </view>
@@ -168,12 +170,19 @@
         </view>
 
         <view class="mb-4">
-          <text class="form-label">选择班组</text>
-          <picker mode="selector" :range="teamOptions" range-key="label" :value="teamIndex" @change="(e: any) => { const idx = e.detail.value; form.teamId = teamOptions[idx].value; onTeamChange(form.teamId) }">
-            <view class="form-field">
-              {{ teamNameDisplay || '请选择班组' }}
+          <text class="form-label">选择人员</text>
+          <view class="flex flex-wrap gap-2">
+            <view
+              v-for="s in staffOptions"
+              :key="s.value"
+              class="staff-chip"
+              :class="form.staffIds.includes(s.value) ? 'staff-chip-active' : ''"
+              @click="toggleStaff(s.value)"
+            >
+              {{ s.label }}
             </view>
-          </picker>
+          </view>
+          <text v-if="staffOptions.length === 0" class="empty-text mt-2">加载人员中...</text>
         </view>
 
         <view class="mb-4">
@@ -188,16 +197,21 @@
         <view class="mb-4">
           <text class="form-label">排班类型</text>
           <view class="shift-tabs">
+            <view
+              class="shift-tab"
+              :class="form.shiftType === 'day' ? 'shift-tab-active' : ''"
+              @click="form.shiftType = 'day'"
+            >白班</view>
+            <view
+              class="shift-tab"
+              :class="form.shiftType === 'night' ? 'shift-tab-active' : ''"
+              @click="form.shiftType = 'night'"
+            >夜班</view>
             <view
               class="shift-tab"
               :class="form.shiftType === 'month_plan' ? 'shift-tab-active' : ''"
               @click="form.shiftType = 'month_plan'"
             >月计划</view>
-            <view
-              class="shift-tab"
-              :class="form.shiftType === 'emergency' ? 'shift-tab-danger' : ''"
-              @click="form.shiftType = 'emergency'"
-            >应急</view>
           </view>
         </view>
 
@@ -252,7 +266,8 @@ import StatusBar from '../common/StatusBar.vue'
 import TopBar from '../common/TopBar.vue'
 import EmptyState from '../common/EmptyState.vue'
 import { getSchedulesByDate, createSchedule, updateSchedule, getScheduleList } from '@/api/schedule'
-import { getTeamSimpleList, getTeamMembers } from '@/api/team'
+import { getTeamSimpleList, getTeamMembers, createTeam, updateTeam } from '@/api/team'
+import { getStaffSimpleList } from '@/api/staff'
 import { getVehicleSimpleList } from '@/api/vehicle'
 
 withDefaults(
@@ -279,21 +294,21 @@ const isDetail = ref(false)
 const currentScheduleId = ref<number | undefined>(undefined)
 const teamOptions = ref<Array<{ value: number; label: string }>>([])
 const vehicleOptions = ref<Array<{ value: number; label: string }>>([])
+const staffOptions = ref<Array<{ value: number; label: string }>>([])
+
+type ShiftType = 'day' | 'night' | 'month_plan' | 'emergency'
 
 const form = ref({
   teamId: undefined as number | undefined,
   teamName: '',
   workDate: '',
-  shiftType: 'month_plan' as 'month_plan' | 'emergency',
+  shiftType: 'month_plan' as ShiftType,
   vehicleIds: [] as number[],
-  staffIds: '',
-  status: 'idle',
+  staffIds: [] as number[],
+  status: 'scheduled',
   remark: '',
 })
 
-const teamIndex = computed(() => teamOptions.value.findIndex(t => t.value === form.value.teamId))
-const teamNameDisplay = computed(() => teamOptions.value.find(t => t.value === form.value.teamId)?.label)
-
 const weekDays = ['日', '一', '二', '三', '四', '五', '六']
 
 const calendarWeeks = computed(() => {
@@ -404,6 +419,14 @@ function getVehicleNamesText(schedule: any): string {
     .join('、')
 }
 
+function getStaffNamesText(schedule: any): string {
+  const ids = parseStaffIds(schedule.staffIds)
+  if (ids.length === 0) return ''
+  return ids
+    .map(id => staffOptions.value.find(s => s.value === id)?.label || String(id))
+    .join('、')
+}
+
 function toggleVehicle(vehicleId: number) {
   const idx = form.value.vehicleIds.indexOf(vehicleId)
   if (idx >= 0) {
@@ -419,11 +442,11 @@ async function addSchedule() {
   resetForm()
   form.value.workDate = selectedDate.value
   showModal.value = true
-  if (teamOptions.value.length === 0) {
+  if (staffOptions.value.length === 0) {
     try {
-      teamOptions.value = await getTeamSimpleList()
+      staffOptions.value = await getStaffSimpleList()
     } catch (e) {
-      console.error('加载班组失败', e)
+      console.error('加载人员失败', e)
     }
   }
   if (vehicleOptions.value.length === 0) {
@@ -435,32 +458,114 @@ async function addSchedule() {
   }
 }
 
-function normalizeShiftType(shiftType: string | undefined): 'month_plan' | 'emergency' {
+const shiftTypeTextMap: Record<string, string> = {
+  day: '白班',
+  night: '夜班',
+  month_plan: '月计划',
+  emergency: '应急',
+}
+
+function getShiftTypeText(shiftType: string | undefined): string {
+  if (!shiftType) return shiftTypeTextMap.month_plan
+  return shiftTypeTextMap[shiftType] || shiftType
+}
+
+function isEmergencyShift(shiftType: string | undefined): boolean {
+  return shiftType === 'emergency' || !!(shiftType && shiftType.includes('应急'))
+}
+
+function normalizeShiftType(shiftType: string | undefined): ShiftType {
   if (!shiftType) return 'month_plan'
-  if (shiftType === 'emergency' || shiftType.includes('应急')) return 'emergency'
+  if (isEmergencyShift(shiftType)) return 'emergency'
+  if (shiftType === 'day' || shiftType === 'night' || shiftType === 'month_plan') return shiftType
   return 'month_plan'
 }
 
-function viewSchedule(schedule: any) {
+const shiftBadgeClassMap: Record<ShiftType, string> = {
+  day: 'shift-badge--day',
+  night: 'shift-badge--night',
+  month_plan: 'shift-badge--month',
+  emergency: 'shift-badge--emergency',
+}
+
+const shiftBadgeIconMap: Record<ShiftType, string> = {
+  day: 'uniui-sunny',
+  night: 'uniui-moon',
+  month_plan: 'uniui-calendar-filled',
+  emergency: 'uniui-info-filled',
+}
+
+function getShiftBadgeClass(schedule: any): string {
+  return shiftBadgeClassMap[(schedule.shiftTypeNorm as ShiftType) || 'month_plan']
+}
+
+function getShiftBadgeIcon(schedule: any): string {
+  return shiftBadgeIconMap[(schedule.shiftTypeNorm as ShiftType) || 'month_plan']
+}
+
+const statusClassMap: Record<string, string> = {
+  idle: 'status-idle',
+  scheduled: 'status-scheduled',
+  in_progress: 'status-in_progress',
+  completed: 'status-completed',
+  cancelled: 'status-cancelled',
+}
+
+function getStatusClass(status: string | undefined): string {
+  return statusClassMap[status || 'idle'] || 'status-idle'
+}
+
+async function viewSchedule(schedule: any) {
   isDetail.value = true
   currentScheduleId.value = schedule.scheduleId
   form.value = {
     teamId: schedule.teamId,
-    teamName: schedule.teamName || '',
+    teamName: schedule.teamName || schedule.scheduleName || '',
     workDate: schedule.workDate || '',
     shiftType: normalizeShiftType(schedule.shiftType),
     vehicleIds: parseVehicleIds(schedule.vehicleIds),
-    staffIds: schedule.staffIds || '',
-    status: schedule.status || 'idle',
+    staffIds: parseStaffIds(schedule.staffIds),
+    status: schedule.status || 'scheduled',
     remark: schedule.remark || '',
   }
   showModal.value = true
-  if (teamOptions.value.length === 0) {
-    getTeamSimpleList().then(res => { teamOptions.value = res }).catch(e => console.error('加载班组失败', e))
+  if (staffOptions.value.length === 0) {
+    getStaffSimpleList().then(res => { staffOptions.value = res }).catch(e => console.error('加载人员失败', e))
   }
   if (vehicleOptions.value.length === 0) {
     getVehicleSimpleList().then(res => { vehicleOptions.value = res }).catch(e => console.error('加载车辆失败', e))
   }
+  if (form.value.staffIds.length === 0 && form.value.teamId) {
+    try {
+      const members = await getTeamMembers(form.value.teamId)
+      form.value.staffIds = members.map(m => m.staffId)
+    } catch (e) {
+      console.error('加载班组成员失败', e)
+    }
+  }
+}
+
+function parseStaffIds(ids: string | number[] | undefined): number[] {
+  if (!ids) return []
+  if (Array.isArray(ids)) return ids.map(Number)
+  return ids.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n))
+}
+
+function toggleStaff(staffId: number) {
+  const idx = form.value.staffIds.indexOf(staffId)
+  if (idx >= 0) {
+    form.value.staffIds.splice(idx, 1)
+  } else {
+    form.value.staffIds.push(staffId)
+  }
+}
+
+function buildTeamName(): string {
+  if (form.value.staffIds.length === 0) return '未命名班组'
+  const names = form.value.staffIds
+    .map(id => staffOptions.value.find(s => s.value === id)?.label || String(id))
+    .filter(Boolean)
+  return names.length ? `${names.join('、')}班组` : '未命名班组'
 }
 
 function parseVehicleIds(ids: string | number[] | undefined): number[] {
@@ -482,26 +587,15 @@ function resetForm() {
     workDate: selectedDate.value,
     shiftType: 'month_plan',
     vehicleIds: [],
-    staffIds: '',
-    status: 'idle',
+    staffIds: [],
+    status: 'scheduled',
     remark: '',
   }
 }
 
-async function onTeamChange(teamId: number) {
-  const team = teamOptions.value.find(t => t.value === teamId)
-  form.value.teamName = team?.label || ''
-  try {
-    const members = await getTeamMembers(teamId)
-    form.value.staffIds = members.map(m => m.staffId).join(',')
-  } catch (e) {
-    console.error('加载班组成员失败', e)
-  }
-}
-
 async function submitSchedule() {
-  if (!form.value.teamId) {
-    uni.showToast({ title: '请选择班组', icon: 'none' })
+  if (form.value.staffIds.length === 0) {
+    uni.showToast({ title: '请选择人员', icon: 'none' })
     return
   }
   if (!form.value.workDate) {
@@ -509,20 +603,42 @@ async function submitSchedule() {
     return
   }
   try {
-    const payload = {
-      teamId: form.value.teamId,
+    const teamName = buildTeamName()
+    const memberIds = form.value.staffIds.join(',')
+    const vehicleIds = form.value.vehicleIds.join(',')
+    let teamId = form.value.teamId
+
+    const teamPayload = {
+      teamName,
+      memberIds,
+      vehicleIds,
+      teamType: 'normal',
+      status: 'standby',
+      remark: form.value.remark || '',
+    }
+
+    if (isDetail.value && teamId) {
+      await updateTeam(teamId, teamPayload)
+    } else {
+      teamId = await createTeam(teamPayload)
+    }
+
+    const schedulePayload = {
+      teamId,
+      scheduleName: teamName,
       workDate: form.value.workDate,
       shiftType: form.value.shiftType,
-      vehicleIds: form.value.vehicleIds.join(','),
-      staffIds: form.value.staffIds,
+      vehicleIds,
+      staffIds: memberIds,
       status: form.value.status,
       remark: form.value.remark,
     }
+
     if (isDetail.value && currentScheduleId.value) {
-      await updateSchedule(currentScheduleId.value, payload)
+      await updateSchedule(currentScheduleId.value, schedulePayload)
       uni.showToast({ title: '保存成功', icon: 'success' })
     } else {
-      await createSchedule(payload)
+      await createSchedule(schedulePayload)
       uni.showToast({ title: '新增排班成功', icon: 'success' })
     }
     closeModal()
@@ -551,6 +667,13 @@ async function fetchSchedules() {
         console.error('加载车辆失败', e)
       }
     }
+    if (staffOptions.value.length === 0) {
+      try {
+        staffOptions.value = await getStaffSimpleList()
+      } catch (e) {
+        console.error('加载人员失败', e)
+      }
+    }
     const [dayRes, monthRes] = await Promise.all([
       getSchedulesByDate(selectedDate.value),
       getScheduleList(currentYear.value, currentMonth.value)
@@ -564,13 +687,23 @@ async function fetchSchedules() {
       }
     })
     allSchedules.forEach((s: any) => {
+      if (!s.teamName) {
+        s.teamName = s.scheduleName || ''
+      }
       if (!s.teamName && s.teamId != null) {
         s.teamName = teamOptions.value.find(t => t.value === Number(s.teamId))?.label || ''
       }
       s.vehicleNamesText = getVehicleNamesText(s)
+      const staffNamesText = getStaffNamesText(s)
+      s.teamDisplayName = s.scheduleName
+        || (staffNamesText ? `${staffNamesText}班组` : '')
+        || s.teamName
+        || '未命名班组'
       s.shiftTypeNorm = normalizeShiftType(s.shiftType)
-      s.shiftTypeText = s.shiftTypeNorm === 'emergency' ? '应急' : '月计划'
+      s.isEmergency = isEmergencyShift(s.shiftType)
+      s.shiftTypeText = getShiftTypeText(s.shiftType)
       s.statusText = getStatusText(s.status)
+      s.statusClass = getStatusClass(s.status)
     })
     schedules.value = allSchedules
   } catch (error) {
@@ -839,82 +972,154 @@ onMounted(() => {
   color: #1f2937;
 }
 
-/* ==================== 列表行 ==================== */
-.list-row {
+/* ==================== 排班卡片 ==================== */
+.schedule-list {
+  padding: 0 16px 16px;
+}
+
+.schedule-card {
+  background-color: #ffffff;
+  border-radius: 18px;
+  padding: 14px 16px;
+  margin-bottom: 12px;
+  border: 1px solid rgba(164, 216, 152, 0.18);
+  box-shadow: 0 4px 14px -4px rgba(54, 143, 111, 0.12);
+}
+
+.schedule-card-hover {
+  transform: translateY(-2px);
+  box-shadow: 0 8px 22px -6px rgba(54, 143, 111, 0.18);
+}
+
+.card-header {
   display: flex;
   align-items: center;
-  padding: 13px 16px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
+  justify-content: space-between;
+  margin-bottom: 10px;
 }
 
-.row-title {
-  display: block;
-  font-size: 14px;
-  font-weight: 500;
-  color: #1f2937;
+.shift-badge {
+  display: inline-flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.shift-badge__icon {
+  margin-right: 4px;
+  font-size: 13px;
+}
+
+.shift-badge--day {
+  color: #368f6f;
+  background-color: rgba(54, 143, 111, 0.12);
 }
 
-.shift-tag {
-  padding: 1px 8px;
+.shift-badge--night {
+  color: #2563eb;
+  background-color: rgba(37, 99, 235, 0.12);
+}
+
+.shift-badge--month {
+  color: #8b5cf6;
+  background-color: rgba(139, 92, 246, 0.12);
+}
+
+.shift-badge--emergency {
+  color: #ef4444;
+  background-color: rgba(239, 68, 68, 0.1);
+}
+
+.status-badge {
+  padding: 2px 8px;
   border-radius: 999px;
   font-size: 11px;
   font-weight: 600;
+}
+
+.status-idle {
+  color: #6b7280;
+  background-color: #f3f4f6;
+}
+
+.status-scheduled {
   color: #368f6f;
-  background-color: rgba(164, 216, 152, 0.24);
+  background-color: rgba(54, 143, 111, 0.1);
 }
 
-.shift-tag-danger {
-  color: #ef4444;
-  background-color: rgba(239, 68, 68, 0.1);
+.status-in_progress {
+  color: #f59e0b;
+  background-color: rgba(245, 158, 11, 0.12);
 }
 
-.row-sub {
-  display: block;
-  margin-top: 3px;
-  font-size: 12px;
+.status-completed {
+  color: #6b7280;
+  background-color: #f3f4f6;
+}
+
+.status-cancelled {
   color: #9ca3af;
+  background-color: #f3f4f6;
 }
 
-.row-hover {
-  background-color: rgba(164, 216, 152, 0.1);
+.card-body {
+  margin-bottom: 10px;
 }
 
-.row-arrow {
-  font-size: 16px;
-  color: #c4d0cb;
-  flex-shrink: 0;
+.team-name {
+  display: block;
+  font-size: 15px;
+  font-weight: 700;
+  color: #1f2937;
+  margin-bottom: 6px;
 }
 
-.status-text {
-  font-size: 12px;
-  font-weight: 600;
-  color: #368f6f;
-  flex-shrink: 0;
+.vehicle-line {
+  display: flex;
+  align-items: center;
 }
 
-/* 嫩芽浅底图标块 */
-.icon-chip {
-  width: 42px;
-  height: 42px;
-  border-radius: 12px;
-  flex-shrink: 0;
+.car-icon {
+  margin-right: 5px;
+  font-size: 13px;
+  color: #9ca3af;
+}
+
+.vehicle-text {
+  font-size: 13px;
+  color: #6b7280;
+}
+
+.card-footer {
   display: flex;
   align-items: center;
-  justify-content: center;
-  background-color: rgba(164, 216, 152, 0.2);
+  justify-content: space-between;
+  padding-top: 10px;
+  border-top: 1px solid rgba(164, 216, 152, 0.12);
 }
 
-.icon-chip-danger {
-  background-color: rgba(239, 68, 68, 0.1);
+.date-line {
+  display: flex;
+  align-items: center;
 }
 
-.chip-glyph {
-  font-size: 20px;
-  color: #368f6f;
+.date-icon {
+  margin-right: 4px;
+  font-size: 12px;
+  color: #9ca3af;
 }
 
-.chip-glyph-danger {
-  color: #ef4444;
+.date-text {
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.row-arrow {
+  font-size: 16px;
+  color: #c4d0cb;
+  flex-shrink: 0;
 }
 
 .empty-text {
@@ -1039,6 +1244,21 @@ onMounted(() => {
   border-color: transparent;
 }
 
+.staff-chip {
+  padding: 6px 12px;
+  border-radius: 10px;
+  font-size: 12px;
+  color: #6b7280;
+  background-color: #ffffff;
+  border: 1px solid #e5e7eb;
+}
+
+.staff-chip-active {
+  color: #ffffff;
+  background: linear-gradient(135deg, #368f6f 0%, #4ba98a 100%);
+  border-color: transparent;
+}
+
 .modal-btn {
   flex: 1;
   padding: 12px 0;

+ 0 - 2
src/components/project/ProjectCard.vue

@@ -20,7 +20,6 @@
       <view class="text-xs text-gray-500 flex items-center">
         <text class="mr-1">类</text>
         <text>{{ getCooperationTypeText(project.cooperationType) }}</text>
-        <text v-if="project.industry" class="ml-2">{{ getIndustryText(project.industry) }}</text>
       </view>
     </view>
 
@@ -45,7 +44,6 @@ import StatusTag from '../common/StatusTag.vue'
 import {
   formatDate,
   getCooperationTypeText,
-  getIndustryText,
   getFacilityTypeText,
   parseJsonArray
 } from '@/utils'

+ 267 - 112
src/components/task/ConstructionTaskCard.vue

@@ -1,59 +1,58 @@
 <template>
   <view
-    class="task-card p-4 clickable"
+    class="task-card clickable"
     hover-class="scale-down"
-    :class="{ 'is-emergency': (task.taskType || task.type) === 'emergency' }"
+    :hover-start-time="0"
+    :hover-stay-time="150"
+    :class="{ 'is-emergency': isEmergency }"
     @click="$emit('click', String((task as any).id ?? (task as any).taskId ?? ''))"
   >
-    <view class="flex justify-between items-start mb-2">
-      <view class="flex-1 min-w-0 mr-3">
-        <text class="text-sm font-semibold text-gray-800 truncate block">{{ (task as any).taskName || task.projectName || '未命名任务' }}</text>
+    <!-- 顶部:项目名/标签 + 状态 -->
+    <view class="task-header">
+      <view class="flex items-center gap-2 min-w-0">
+        <text class="project-name truncate">{{ projectNameText }}</text>
+        <view v-if="coopTagText" class="tag coop-tag">{{ coopTagText }}</view>
+        <view v-if="isEmergency" class="tag emergency-tag">应急</view>
+        <view v-else-if="urgentText" class="tag" :class="urgentClass">{{ urgentText }}</view>
       </view>
       <StatusTag :status="task.status" />
     </view>
 
-    <view class="flex flex-wrap gap-2 mb-3">
-      <view v-if="task.urgent === 'high'" class="px-2 py-1 bg-danger-light text-danger rounded text-xs font-medium">
-        加急
+    <!-- 信息行 -->
+    <view class="info-list">
+      <view class="info-row">
+        <text class="uni-icons uniui-tools info-icon"></text>
+        <text class="info-text">{{ serviceTypeText }}</text>
       </view>
-      <view v-if="task.coopType" class="chip-type px-2 py-1 rounded text-xs font-medium">
-        {{ task.coopType }}
+      <view class="info-row">
+        <text class="uni-icons uniui-clock-filled info-icon"></text>
+        <text class="info-text">服务时间:{{ serviceTimeText }}</text>
       </view>
-      <view v-if="task.serviceMode" class="px-2 py-1 bg-success-light text-success rounded text-xs font-medium">
-        {{ task.serviceMode }}
+      <view class="info-row">
+        <text class="uni-icons uniui-location-filled info-icon"></text>
+        <text class="info-text truncate">{{ task.address || '暂无地址' }}</text>
       </view>
     </view>
 
-    <view class="gap-y-2 mb-3">
-      <view class="flex items-center">
-        <text class="uni-icons uniui-tools info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs">{{ task.serviceType || '施工服务' }}</text>
+    <!-- 当前步骤 + 流程进度 -->
+    <view class="step-box">
+      <view class="step-box-header">
+        <text class="step-box-label">当前步骤</text>
+        <text class="step-box-value" :style="{ color: stepColor }">{{ currentStepLabel }}</text>
       </view>
-      <view class="flex items-center">
-        <text class="uni-icons uniui-clock-filled info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs">{{ task.serviceDate || task.scheduleDate || '待定' }} {{ task.serviceTime || task.scheduleTime || '' }}</text>
-      </view>
-      <view class="flex items-center">
-        <text class="uni-icons uniui-location-filled info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs truncate">{{ task.address || '暂无地址' }}</text>
-      </view>
-    </view>
-
-    <view class="pt-3 border-t border-gray-100">
-      <view class="flow-row">
+      <view class="flow-progress">
         <view
-          v-for="(step, i) in steps"
-          :key="step.key"
+          v-for="(step, i) in flowSteps"
+          :key="i"
           class="flow-step"
+          :class="{ 'completed': step.completed, 'active': step.active }"
         >
-          <view class="flow-node-wrap">
-            <view v-if="i !== 0" class="flow-line" :class="step.active ? 'flow-line-active' : ''" />
-            <view class="flow-node" :class="step.active ? 'flow-node-active' : (step.current ? 'flow-node-current' : '')">
-              <text v-if="step.active" class="uni-icons uniui-checkmarkempty flow-check"></text>
-              <text v-else class="flow-node-text">{{ i + 1 }}</text>
-            </view>
+          <view class="step-icon">
+            <text v-if="step.completed" class="uni-icons uniui-checkmarkempty step-check"></text>
+            <text v-else-if="step.active" class="uni-icons uniui-reload step-spinner"></text>
+            <text v-else class="step-number-text">×</text>
           </view>
-          <text class="flow-label" :class="step.active ? 'flow-label-active' : ''">{{ step.label }}</text>
+          <text class="step-label">{{ step.label }}</text>
         </view>
       </view>
     </view>
@@ -61,10 +60,19 @@
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
 import StatusTag from '../common/StatusTag.vue'
 import type { Task } from '@/types'
-import { SOP_STEPS } from '@/constants/sop'
+import { formatDate, getConstructionTypeText, getCooperationTypeText } from '@/utils'
+import { getTaskPhotos, type TaskPhoto } from '@/api/file'
+import {
+  FLOW_STEPS,
+  RECORD_STEPS,
+  isRecordStepComplete,
+  completedRecordStepCount,
+  computeCurrentStepIndex,
+  getFlowStepState,
+} from '@/constants/sop'
 
 const props = defineProps<{
   task: Task
@@ -74,115 +82,262 @@ defineEmits<{
   click: [taskId: string]
 }>()
 
-const steps = computed(() => {
-  const current = Number(props.task.currentStep) || 0
-  return SOP_STEPS.map((s, i) => ({
-    key: s.code,
-    label: s.title.replace('任务', '').replace('准备', '出车').replace('现场', '到达').replace('签字', '验收'),
-    active: current >= s.step,
-    current: current + 1 === s.step,
-  }))
+const taskId = computed(() => Number((props.task as any).id ?? (props.task as any).taskId ?? 0))
+
+const isEmergency = computed(() => (props.task.taskType || props.task.type) === 'emergency')
+
+const projectNameText = computed(() => {
+  return props.task.projectName || (props.task as any).taskName || '未命名任务'
+})
+
+const coopTagText = computed(() => {
+  return getCooperationTypeText(props.task.coopType)
+})
+
+const urgentText = computed(() => {
+  if (props.task.urgent === 'high' || props.task.urgent === 'very_urgent' || props.task.urgencyLevel === 'high') return '加急'
+  return ''
+})
+
+const urgentClass = computed(() => {
+  return 'urgent-tag'
+})
+
+const serviceTypeText = computed(() => {
+  const raw = props.task.serviceType || (props.task as any).constructionType
+  return raw ? getConstructionTypeText(raw) : '施工服务'
+})
+
+const serviceTimeText = computed(() => {
+  const date = props.task.serviceDate || props.task.scheduleDate || props.task.planDate || ''
+  const time = props.task.serviceTime || props.task.scheduleTime || ''
+  if (date && time) return `${date} ${time}`
+  if (date) return date
+  if ((props.task as any).expectedArrivalTime) return formatDate((props.task as any).expectedArrivalTime, 'MM-DD HH:mm')
+  return '待定'
+})
+
+const photos = ref<TaskPhoto[]>([])
+const photoLoading = ref(false)
+
+const completedRecordStepCountValue = computed(() => completedRecordStepCount(photos.value))
+
+/** 当前进行到的步骤索引(与任务详情页顶部流程条完全对齐) */
+const currentStepIndex = computed(() => computeCurrentStepIndex(props.task, photos.value))
+
+const currentStepLabel = computed(() => {
+  if (props.task.status === 'completed') return '已完成'
+  if (props.task.status === 'cancelled') return '已取消'
+  if (props.task.status === 'rejected') return '已拒绝'
+  const idx = currentStepIndex.value
+  return FLOW_STEPS[idx - 1]?.title || '待确认'
+})
+
+const stepColor = computed(() => {
+  const status = props.task.status
+  if (status === 'completed') return '#10b981'
+  if (status === 'cancelled' || status === 'rejected') return '#ef4444'
+  return '#3b82f6'
+})
+
+/** 每个流程步骤的状态(与任务详情页 getFlowStepState 对齐,支持非连续完成) */
+const flowSteps = computed(() => {
+  return FLOW_STEPS.map((s, i) => {
+    const uiStep = i + 1
+    const state = getFlowStepState(props.task, photos.value, uiStep)
+    return {
+      label: s.title,
+      completed: state === 'completed',
+      active: state === 'current',
+    }
+  })
 })
+
+async function loadPhotos() {
+  if (!taskId.value) return
+  if (props.task.status === 'completed' || props.task.status === 'cancelled' || props.task.status === 'rejected') return
+  photoLoading.value = true
+  try {
+    photos.value = await getTaskPhotos(taskId.value)
+  } catch (e) {
+    console.error('加载任务照片失败', e)
+  } finally {
+    photoLoading.value = false
+  }
+}
+
+onMounted(() => {
+  loadPhotos()
+})
+
+watch(() => props.task, () => {
+  loadPhotos()
+}, { deep: false })
 </script>
 
 <style scoped>
 .task-card {
-  background-color: rgba(255, 255, 255, 0.85);
-  backdrop-filter: blur(14px);
-  -webkit-backdrop-filter: blur(14px);
-  border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
-  box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
+  background: #ffffff;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
   overflow: hidden;
+  transition: transform 0.15s ease;
 }
 .task-card.is-emergency {
-  border-left: 4px solid #ef4444;
+  border-left: 8rpx solid #ef4444;
 }
-.info-icon {
-  color: #368f6f;
+
+.task-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+.project-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333333;
+  line-height: 1.4;
+  max-width: 60%;
+}
+.tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 4rpx 12rpx;
+  border-radius: 6rpx;
+  font-size: 22rpx;
+  line-height: 1;
+  flex-shrink: 0;
 }
-.chip-type {
-  background-color: rgba(164, 216, 152, 0.2);
-  color: #368f6f;
+.coop-tag {
+  background: #f3e8ff;
+  color: #9333ea;
 }
-/* 流程条:与任务详情 SOP 流程条一致 */
-.flow-row {
+.emergency-tag {
+  background: #fef2f2;
+  color: #dc2626;
+}
+.urgent-tag {
+  background: #fff7ed;
+  color: #ea580c;
+}
+
+.info-list {
+  margin-bottom: 24rpx;
+}
+.info-row {
   display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
 }
-.flow-step {
+.info-row:last-child {
+  margin-bottom: 0;
+}
+.info-icon {
+  color: #9ca3af;
+  font-size: 24rpx;
+  margin-right: 16rpx;
+  width: 40rpx;
+  text-align: center;
+}
+.info-text {
+  font-size: 26rpx;
+  color: #666666;
   flex: 1;
+}
+
+.step-box {
+  background: #eff6ff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.step-box-header {
   display: flex;
-  flex-direction: column;
+  justify-content: space-between;
   align-items: center;
-  min-width: 0;
+  margin-bottom: 20rpx;
+}
+.step-box-label {
+  font-size: 24rpx;
+  color: #6b7280;
+}
+.step-box-value {
+  font-size: 24rpx;
+  font-weight: 500;
+  color: #3b82f6;
 }
-.flow-node-wrap {
-  width: 100%;
-  height: 24px;
+
+.flow-progress {
+  display: flex;
+  justify-content: space-between;
   position: relative;
+  padding: 0 6rpx;
+}
+.flow-progress::before {
+  content: '';
+  position: absolute;
+  top: 15rpx;
+  left: 24rpx;
+  right: 24rpx;
+  height: 3rpx;
+  background: #dbeafe;
+}
+.flow-step {
   display: flex;
+  flex-direction: column;
   align-items: center;
-  justify-content: center;
+  z-index: 1;
+  position: relative;
+  flex: 1;
+  min-width: 0;
 }
-.flow-line {
-  position: absolute;
-  top: 50%;
-  left: 0;
-  right: 50%;
-  height: 2px;
-  background-color: #e5e7eb;
-  transform: translateY(-50%);
-}
-.flow-line-active {
-  background-color: #368f6f;
-}
-.flow-node {
-  width: 22px;
-  height: 22px;
+.step-icon {
+  width: 30rpx;
+  height: 30rpx;
   border-radius: 50%;
-  background-color: #f3f4f6;
-  border: 2px solid #e5e7eb;
+  background: #dbeafe;
   display: flex;
   align-items: center;
   justify-content: center;
-  z-index: 1;
+  margin-bottom: 8rpx;
 }
-.flow-node-active {
-  background-color: #368f6f;
-  border-color: #368f6f;
+.flow-step.completed .step-icon {
+  background: #3b82f6;
+  color: #ffffff;
 }
-.flow-node-current {
-  background-color: #fff;
-  border-color: #368f6f;
-  box-shadow: 0 0 0 3px rgba(54, 143, 111, 0.2);
+.flow-step.active .step-icon {
+  background: #3b82f6;
+  color: #ffffff;
+  box-shadow: 0 0 0 8rpx rgba(59, 130, 246, 0.3);
 }
-.flow-check {
-  color: #fff;
-  font-size: 11px;
+.step-check {
+  font-size: 20rpx;
+  color: #ffffff;
 }
-.flow-node-text {
-  font-size: 10px;
-  color: #9ca3af;
+.step-spinner {
+  font-size: 18rpx;
+  color: #ffffff;
 }
-.flow-node-current .flow-node-text {
-  color: #368f6f;
-  font-weight: 600;
+.step-number-text {
+  font-size: 18rpx;
+  color: #93c5fd;
 }
-.flow-label {
-  margin-top: 6px;
-  font-size: 10px;
+.step-label {
+  font-size: 18rpx;
   color: #9ca3af;
   text-align: center;
+  transform: scale(0.9);
 }
-.flow-label-active {
-  color: #368f6f;
+.flow-step.completed .step-label,
+.flow-step.active .step-label {
+  color: #3b82f6;
+  font-weight: 500;
 }
+
 .scale-down {
+  transform: scale(0.96);
   transition: transform 0.15s ease;
 }
-.scale-down:active {
-  transform: scale(0.95);
-}
 </style>

+ 156 - 80
src/components/task/DispatchTaskCard.vue

@@ -1,46 +1,44 @@
 <template>
   <view
-    class="task-card p-4 clickable"
+    class="task-card clickable"
     hover-class="scale-down"
+    :hover-start-time="0"
+    :hover-stay-time="150"
     :style="borderStyle"
     @click="$emit('click', String((task as any).id ?? (task as any).taskId ?? ''))"
   >
-    <view class="flex justify-between items-start mb-2">
-      <view class="flex-1 min-w-0 mr-3">
-        <text class="text-sm font-semibold text-gray-800 truncate block">{{ (task as any).taskName || task.projectName || (task as any).serviceContent || (task as any).faultLocation || (task as any).taskNo || '未命名任务' }}</text>
-      </view>
+    <!-- 顶部:标题 + 状态 -->
+    <view class="task-header">
+      <text class="task-title truncate">{{ titleText }}</text>
       <StatusTag :status="task.status" />
     </view>
 
-    <view class="gap-y-2 mb-3">
-      <view class="flex items-center">
-        <text class="uni-icons uniui-location-filled info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs truncate">{{ task.address || '暂无地址' }}</text>
-      </view>
-      <view class="flex items-center">
-        <text class="uni-icons uniui-tools info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs">
-          {{ task.serviceType || '施工服务' }}
-          <text v-if="task.facilityType" class="text-gray-400"> / {{ task.facilityType }}</text>
-        </text>
+    <!-- 信息行 -->
+    <view class="info-list">
+      <view class="info-row">
+        <text class="uni-icons uniui-location-filled info-icon"></text>
+        <text class="info-text truncate">{{ task.address || '暂无地址' }}</text>
       </view>
-      <view class="flex items-center">
-        <text class="uni-icons uniui-clock-filled info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs">{{ formatTime }}</text>
+      <view class="info-row">
+        <text class="uni-icons uniui-gear-filled info-icon"></text>
+        <text class="info-text">{{ serviceTypeText }}</text>
       </view>
-      <view v-if="task.applicant" class="flex items-center">
-        <text class="uni-icons uniui-person-filled info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs">申请人:{{ task.applicant }}</text>
+      <view class="info-row">
+        <text class="uni-icons uniui-calendar-filled info-icon"></text>
+        <text class="info-text">{{ formatTime }}</text>
       </view>
     </view>
 
-    <view class="pt-2 border-t border-gray-100 flex justify-between items-center">
-      <text class="text-xs text-gray-400">{{ statusHint }}</text>
-      <text class="text-xs font-medium" :style="{ color: actionColor }">{{ actionText }}</text>
+    <!-- 底部:申请人 + 操作提示 -->
+    <view class="footer">
+      <text class="footer-hint">{{ applicantText }}</text>
+      <text class="footer-action" :style="{ color: actionColor }">{{ actionText }}</text>
     </view>
 
-    <view v-if="rejectReasonText" class="pt-2 border-t border-gray-100">
-      <text class="text-xs text-danger">驳回原因:{{ rejectReasonText }}</text>
+    <!-- 驳回原因 -->
+    <view v-if="rejectReasonText" class="reject-reason">
+      <text class="uni-icons uniui-info-filled reject-icon"></text>
+      <text class="reject-text">驳回原因:{{ rejectReasonText }}</text>
     </view>
   </view>
 </template>
@@ -49,7 +47,7 @@
 import { computed } from 'vue'
 import StatusTag from '../common/StatusTag.vue'
 import type { Task } from '@/types'
-import { formatDate } from '@/utils'
+import { formatDate, getConstructionTypeText, getFacilityTypeText } from '@/utils'
 
 const props = defineProps<{
   task: Task
@@ -59,33 +57,50 @@ defineEmits<{
   click: [taskId: string]
 }>()
 
+const titleText = computed(() => {
+  return (props.task as any).taskName
+    || props.task.projectName
+    || (props.task as any).serviceContent
+    || props.task.faultLocation
+    || (props.task as any).taskNo
+    || '未命名任务'
+})
+
 const borderStyle = computed(() => {
-  if (props.task.type === 'emergency') {
-    return { borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: '#ef4444' }
-  }
   const color = {
     pending: '#f59e0b',
     auditing: '#f59e0b',
-    approved: '#2563eb',
-    assigned: '#2563eb',
-    in_progress: '#2563eb',
-    confirmed: '#368f6f',
-    scheduled: '#368f6f',
-    departed: '#5ab8d0',
-    arrived: '#10b981',
-    constructing: '#368f6f',
-    inspecting: '#f59e0b',
+    approved: '#f59e0b',
+    assigned: '#3b82f6',
+    in_progress: '#3b82f6',
+    confirmed: '#eab308',
+    scheduled: '#3b82f6',
+    departed: '#3b82f6',
+    arrived: '#3b82f6',
+    constructing: '#3b82f6',
+    inspecting: '#3b82f6',
     completed: '#10b981',
     cancelled: '#ef4444',
     rejected: '#ef4444',
-  }[props.task.status] || '#368f6f'
-  return { borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: color } as any
+  }[props.task.status] || '#e5e7eb'
+  return {
+    borderLeftWidth: '8rpx',
+    borderLeftStyle: 'solid',
+    borderLeftColor: color,
+  } as any
+})
+
+const serviceTypeText = computed(() => {
+  const rawService = props.task.serviceType || (props.task as any).constructionType
+  const service = rawService ? getConstructionTypeText(rawService) : '施工服务'
+  const facility = props.task.facilityType || (props.task as any).constructionFacility
+  return facility ? `${service} / ${getFacilityTypeText(facility)}` : service
 })
 
 const formatTime = computed(() => {
-  if (props.task.applyTime) return formatDate(props.task.applyTime, 'MM-DD HH:mm')
-  if (props.task.createTime) return formatDate(props.task.createTime, 'MM-DD HH:mm')
-  return '待定'
+  if (props.task.applyTime) return `申请时间:${formatDate(props.task.applyTime, 'MM-DD HH:mm')}`
+  if (props.task.createTime) return `申请时间:${formatDate(props.task.createTime, 'MM-DD HH:mm')}`
+  return '时间待定'
 })
 
 const rejectReasonText = computed(() => {
@@ -93,61 +108,122 @@ const rejectReasonText = computed(() => {
   return props.task.rejectReason || (props.task as any).auditRemark || ''
 })
 
-const statusHint = computed(() => {
-  const map: Record<string, string> = {
-    pending: '需调度审核',
-    auditing: '需调度审核',
-    approved: '待安排班组',
-    assigned: '已安排班组',
-    in_progress: '进行中',
-    confirmed: '待安排班组',
-    scheduled: '已安排,待出车',
-    departed: '已出车',
-    arrived: '已到达现场',
-    constructing: '正在施工',
-    inspecting: '待验收',
-    completed: '已完成',
-    rejected: '已驳回',
-    cancelled: '已取消',
-  }
-  return map[props.task.status] || '待处理'
+const applicantText = computed(() => {
+  return props.task.applicant ? `申请人:${props.task.applicant}` : '申请人:-'
 })
 
 const actionText = computed(() => {
   const map: Record<string, string> = {
-    pending: '去审核',
-    auditing: '去审核',
-    approved: '去安排',
-    confirmed: '去排班',
+    pending: '需调度安排',
+    auditing: '需调度安排',
+    approved: '待安排班组',
+    assigned: '查看',
+    in_progress: '查看',
+    confirmed: '待安排班组',
     scheduled: '查看',
     rejected: '查看',
+    completed: '查看',
+    cancelled: '查看',
   }
-  return map[props.task.status] || '查看'
+  return map[props.task.status] || '去处理'
 })
 
 const actionColor = computed(() => {
-  if (['pending', 'auditing', 'approved', 'confirmed'].includes(props.task.status)) return '#368f6f'
-  return '#9ca3af'
+  if (['pending', 'auditing', 'approved', 'confirmed'].includes(props.task.status)) return '#f59e0b'
+  if (props.task.status === 'rejected' || props.task.status === 'cancelled') return '#ef4444'
+  if (props.task.status === 'completed') return '#10b981'
+  return '#3b82f6'
 })
 </script>
 
 <style scoped>
 .task-card {
-  background-color: rgba(255, 255, 255, 0.85);
-  backdrop-filter: blur(14px);
-  -webkit-backdrop-filter: blur(14px);
-  border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
-  box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
+  background: #ffffff;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
   overflow: hidden;
+  transition: transform 0.15s ease;
+}
+
+.task-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+.task-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333333;
+  flex: 1;
+  margin-right: 24rpx;
+  line-height: 1.4;
+}
+
+.info-list {
+  margin-bottom: 24rpx;
+}
+.info-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.info-row:last-child {
+  margin-bottom: 0;
 }
 .info-icon {
-  color: #368f6f;
+  color: #9ca3af;
+  font-size: 24rpx;
+  margin-right: 16rpx;
+  width: 40rpx;
+  text-align: center;
+}
+.info-text {
+  font-size: 26rpx;
+  color: #666666;
+  flex: 1;
+}
+
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-top: 20rpx;
+  border-top: 1rpx solid #f3f4f6;
 }
+.footer-hint {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+.footer-action {
+  font-size: 24rpx;
+  font-weight: 500;
+}
+
+.reject-reason {
+  display: flex;
+  align-items: flex-start;
+  margin-top: 20rpx;
+  padding-top: 20rpx;
+  border-top: 1rpx solid #f3f4f6;
+}
+.reject-icon {
+  color: #ef4444;
+  font-size: 24rpx;
+  margin-right: 12rpx;
+  margin-top: 4rpx;
+}
+.reject-text {
+  font-size: 24rpx;
+  color: #ef4444;
+  flex: 1;
+  line-height: 1.5;
+}
+
 .scale-down {
+  transform: scale(0.96);
   transition: transform 0.15s ease;
 }
-.scale-down:active {
-  transform: scale(0.95);
-}
 </style>

+ 245 - 58
src/components/task/SalesTaskCard.vue

@@ -1,55 +1,63 @@
 <template>
   <view
-    class="task-card p-4 clickable"
+    class="task-card clickable"
     hover-class="scale-down"
     :hover-start-time="0"
     :hover-stay-time="150"
-    :class="{
-      'border-l-4 border-red-500': task.type === 'emergency'
-    }"
+    :class="cardClass"
     @click="$emit('click', String((task as any).id ?? (task as any).taskId ?? ''))"
   >
-    <view class="flex justify-between items-start mb-2">
-      <view class="flex-1 min-w-0 mr-3">
-        <text class="text-sm font-semibold text-gray-800 truncate block">{{ (task as any).taskName || task.projectName || '未命名任务' }}</text>
+    <!-- 顶部:状态/标签 + 时间 -->
+    <view class="header">
+      <view class="flex items-center gap-2">
+        <StatusTag :status="task.status" />
+        <view v-if="urgentText" class="urgent-tag" :class="urgentClass">
+          {{ urgentText }}
+        </view>
+        <view v-if="isEmergency" class="urgent-tag urgent-high">
+          应急
+        </view>
       </view>
-      <StatusTag :status="task.status" />
+      <text class="time-text">{{ formatTime }}</text>
     </view>
 
-    <view class="flex flex-wrap gap-2 mb-3">
-      <view v-if="task.urgent === 'high'" class="px-2 py-1 bg-danger-light text-danger rounded text-xs font-medium">
-        加急
-      </view>
-      <view v-if="task.type === 'emergency'" class="px-2 py-1 bg-warning-light text-warning rounded text-xs font-medium">
-        应急
-      </view>
-      <view v-if="task.coopType" class="chip-type px-2 py-1 rounded text-xs font-medium">
-        {{ task.coopType }}
-      </view>
-    </view>
+    <!-- 任务名称 -->
+    <view class="task-name">{{ (task as any).taskName || task.projectName || '未命名任务' }}</view>
 
-    <view class="gap-y-1 mb-3">
-      <view class="flex items-center">
-        <text class="uni-icons uniui-location-filled info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs truncate">{{ task.address || '暂无地址' }}</text>
+    <!-- 信息行 -->
+    <view class="info-list">
+      <view class="info-row">
+        <text class="uni-icons uniui-location-filled info-icon"></text>
+        <text class="info-text truncate">{{ task.address || '暂无地址' }}</text>
       </view>
-      <view v-if="task.team" class="flex items-center">
-        <text class="uni-icons uniui-person-filled info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs">施工班组:{{ task.team }}</text>
+      <view class="info-row">
+        <text class="uni-icons uniui-person-filled info-icon"></text>
+        <text class="info-text">施工班组:{{ task.teamName || task.team || '待分配' }}</text>
       </view>
-      <view v-if="task.acceptType" class="flex items-center">
-        <text class="uni-icons uniui-checkmarkempty info-icon text-xs mr-1"></text>
-        <text class="text-gray-500 text-xs">验收方式:{{ task.acceptType }}</text>
+      <view class="info-row">
+        <text class="uni-icons uniui-checkmarkempty info-icon"></text>
+        <text class="info-text">验收:{{ acceptTypeText }}</text>
       </view>
     </view>
 
-    <view class="pt-3 border-t border-gray-100">
-      <view class="flex justify-between text-xs text-gray-400 mb-1">
-        <text>进度</text>
-        <text>{{ progressPercent }}%</text>
+    <!-- 底部进度条 -->
+    <view class="progress-section">
+      <view class="progress-bar-track">
+        <view class="progress-bar-fill" :style="{ width: `${progressPercent}%` }"></view>
+        <view
+          v-for="(step, i) in progressSteps"
+          :key="i"
+          class="progress-bar-step"
+          :class="{ 'completed': step.active, 'active': step.current }"
+        />
       </view>
-      <view class="progress-track h-2 rounded-full overflow-hidden">
-        <view class="progress-fill h-full rounded-full" :style="{ width: `${progressPercent}%` }"></view>
+      <view class="progress-bar-labels">
+        <text
+          v-for="(step, i) in progressSteps"
+          :key="i"
+          class="progress-bar-label"
+          :class="{ 'active': step.active }"
+        >{{ step.label }}</text>
       </view>
     </view>
   </view>
@@ -59,6 +67,7 @@
 import { computed } from 'vue'
 import StatusTag from '../common/StatusTag.vue'
 import type { Task } from '@/types'
+import { formatDate, getAcceptanceMethodText } from '@/utils'
 
 const props = defineProps<{
   task: Task
@@ -68,16 +77,87 @@ defineEmits<{
   click: [taskId: string]
 }>()
 
+const isEmergency = computed(() => (props.task.taskType || props.task.type) === 'emergency')
+
+const urgentText = computed(() => {
+  if (props.task.urgent === 'high' || props.task.urgent === 'very_urgent' || props.task.urgencyLevel === 'high') return '紧急'
+  if (props.task.urgent === 'normal' || props.task.urgencyLevel === 'normal') return '普通'
+  return ''
+})
+
+const urgentClass = computed(() => {
+  const urgent = props.task.urgent || props.task.urgencyLevel
+  return ['high', 'very_urgent', 'urgent'].includes(urgent as string) ? 'urgent-high' : 'urgent-normal'
+})
+
+const cardClass = computed(() => {
+  if (props.task.status === 'completed') return 'is-completed'
+  if (isEmergency.value) return 'is-emergency'
+  // 暂无超时字段,保留类名供后续扩展
+  return ''
+})
+
+const formatTime = computed(() => {
+  if (props.task.applyTime) return formatDate(props.task.applyTime, 'MM-DD HH:mm')
+  if (props.task.createTime) return formatDate(props.task.createTime, 'MM-DD HH:mm')
+  return ''
+})
+
+const acceptTypeText = computed(() => {
+  const raw = props.task.acceptType || props.task.acceptanceMethod
+  return raw ? getAcceptanceMethodText(raw) : '签署验收计量单'
+})
+
+const progressSteps = computed(() => {
+  const status = props.task.status
+  const steps = [
+    { label: '发布', active: true, current: false },
+    { label: '调度确认', active: false, current: false },
+    { label: '施工中', active: false, current: false },
+    { label: '完成', active: false, current: false },
+  ]
+
+  const order = ['pending', 'auditing', 'rejected', 'confirmed', 'scheduled', 'departed', 'arrived', 'constructing', 'inspecting', 'completed', 'cancelled']
+  const idx = order.indexOf(status)
+
+  if (idx >= order.indexOf('confirmed')) {
+    steps[1].active = true
+  }
+  if (idx >= order.indexOf('scheduled')) {
+    steps[1].current = false
+  }
+  if (['departed', 'arrived', 'constructing', 'inspecting'].includes(status)) {
+    steps[2].active = true
+    steps[2].current = true
+  }
+  if (status === 'completed') {
+    steps[2].active = true
+    steps[2].current = false
+    steps[3].active = true
+    steps[3].current = true
+  }
+
+  // 当前步骤高亮:调度确认/施工中
+  if (['pending', 'auditing', 'rejected'].includes(status)) {
+    steps[1].current = true
+  } else if (['confirmed', 'scheduled'].includes(status)) {
+    steps[2].current = true
+  }
+
+  return steps
+})
+
 const progressPercent = computed(() => {
   const map: Record<string, number> = {
     pending: 0,
+    auditing: 0,
     rejected: 0,
-    confirmed: 25,
-    scheduled: 50,
-    departed: 60,
-    arrived: 70,
-    constructing: 75,
-    inspecting: 90,
+    confirmed: 33,
+    scheduled: 33,
+    departed: 66,
+    arrived: 66,
+    constructing: 66,
+    inspecting: 66,
     completed: 100,
     cancelled: 0,
   }
@@ -87,32 +167,139 @@ const progressPercent = computed(() => {
 
 <style scoped>
 .task-card {
-  background-color: rgba(255, 255, 255, 0.85);
-  backdrop-filter: blur(14px);
-  -webkit-backdrop-filter: blur(14px);
-  border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
-  box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
+  background: #ffffff;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
   overflow: hidden;
+  transition: transform 0.15s ease;
+}
+.task-card.is-emergency {
+  border-left: 8rpx solid #ef4444;
+}
+.task-card.is-completed {
+  background: #f0fdf4;
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+
+.urgent-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 4rpx 12rpx;
+  border-radius: 6rpx;
+  font-size: 22rpx;
+  line-height: 1;
 }
-.border-l-4 {
-  border-left-width: 4px;
+.urgent-high {
+  background: #fef2f2;
+  color: #dc2626;
+}
+.urgent-normal {
+  background: #f3f4f6;
+  color: #6b7280;
+}
+
+.time-text {
+  font-size: 24rpx;
+  color: #9ca3af;
+  flex-shrink: 0;
+}
+
+.task-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333333;
+  margin-bottom: 20rpx;
+  line-height: 1.4;
+}
+
+.info-list {
+  margin-bottom: 24rpx;
+}
+.info-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.info-row:last-child {
+  margin-bottom: 0;
 }
 .info-icon {
-  color: #368f6f;
+  color: #999999;
+  font-size: 24rpx;
+  margin-right: 12rpx;
+  width: 40rpx;
+  text-align: center;
+}
+.info-text {
+  font-size: 26rpx;
+  color: #666666;
+  flex: 1;
+}
+
+.progress-section {
+  margin-top: 8rpx;
+  padding: 0 8rpx;
 }
-.chip-type {
-  background-color: rgba(164, 216, 152, 0.2);
-  color: #368f6f;
+.progress-bar-track {
+  height: 8rpx;
+  background: #e5e7eb;
+  border-radius: 4rpx;
+  position: relative;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
 }
-.progress-track {
-  background-color: rgba(164, 216, 152, 0.18);
+.progress-bar-fill {
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  background: #3b82f6;
+  border-radius: 4rpx;
+  transition: width 0.3s ease;
 }
-.progress-fill {
-  background: linear-gradient(90deg, #368f6f 0%, #5ab8d0 100%);
+.progress-bar-step {
+  width: 24rpx;
+  height: 24rpx;
+  border-radius: 50%;
+  background: #e5e7eb;
+  border: 4rpx solid #ffffff;
+  position: relative;
+  z-index: 1;
+  flex-shrink: 0;
 }
+.progress-bar-step.completed {
+  background: #3b82f6;
+}
+.progress-bar-step.active {
+  background: #3b82f6;
+  box-shadow: 0 0 0 6rpx rgba(59, 130, 246, 0.3);
+}
+.progress-bar-labels {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 12rpx;
+}
+.progress-bar-label {
+  font-size: 20rpx;
+  color: #9ca3af;
+  text-align: center;
+}
+.progress-bar-label.active {
+  color: #3b82f6;
+  font-weight: 500;
+}
+
 .scale-down {
-  transform: scale(0.95);
+  transform: scale(0.96);
   transition: transform 0.15s ease;
 }
 </style>

+ 61 - 0
src/composables/useDictOptions.ts

@@ -0,0 +1,61 @@
+import { ref, type Ref } from 'vue'
+import { getDictTree, buildDictOptions, type DictOptions } from '@/api/dict'
+
+interface UseDictOptionsReturn {
+  options: Ref<DictOptions>
+  loading: Ref<boolean>
+  load: () => Promise<void>
+}
+
+/**
+ * 通用字典选项加载 hook
+ * @param dictType 字典类型,如 construction_type / construction_facility
+ * @param fallback 字典接口失败时的兜底选项
+ */
+export function useDictOptions(
+  dictType: string,
+  fallback: { value: string; label: string }[] = []
+): UseDictOptionsReturn {
+  const options = ref<DictOptions>({
+    options: fallback,
+    labelMap: fallback.reduce<Record<string, string>>((acc, item) => {
+      acc[item.value] = item.label
+      return acc
+    }, {}),
+    colorMap: {},
+    data: []
+  })
+  const loading = ref(false)
+
+  async function load() {
+    if (loading.value) return
+    loading.value = true
+    try {
+      const data = await getDictTree(dictType)
+      const opts = buildDictOptions(data)
+      if (opts.options.length > 0) {
+        options.value = opts
+      }
+    } catch {
+      // 失败时保持兜底
+    } finally {
+      loading.value = false
+    }
+  }
+
+  return { options, loading, load }
+}
+
+/**
+ * 批量加载多个字典类型
+ * @param dictTypes 字典类型数组
+ * @returns 每个类型对应的 DictOptions ref 对象
+ */
+export function useMultipleDictOptions(
+  dictTypes: string[]
+): Record<string, UseDictOptionsReturn> {
+  return dictTypes.reduce<Record<string, UseDictOptionsReturn>>((acc, type) => {
+    acc[type] = useDictOptions(type)
+    return acc
+  }, {})
+}

+ 550 - 0
src/composables/usePublishTaskForm.ts

@@ -0,0 +1,550 @@
+import { ref, computed, watch, type Ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { useProjectStore } from '@/stores/project'
+import { useDictOptions } from './useDictOptions'
+import { createTask, type TaskParams } from '@/api/task'
+import {
+  showToast,
+  navigateTo,
+  getCooperationTypeText,
+  getFacilityTypeText,
+  parseJsonArray
+} from '@/utils'
+import type { Project, ServicePoint } from '@/types'
+
+export interface PublishTaskForm {
+  // 项目基本信息
+  projectId?: number
+  projectName: string
+  servicePointId?: number
+  servicePointName: string
+  cooperationType: string
+  facilityType: string
+  address: string
+  contactName: string
+  contactPhone: string
+  contacts: { name: string; phone: string }[]
+
+  // 服务信息
+  serviceTime: string        // YYYY-MM-DDTHH:mm:ss
+  faultLocation: string
+  geoLocation: string
+  latitude: string
+  longitude: string
+  faultDescription: string
+  faultPhotos: string[]
+  constructionType: string
+  constructionFacility: string
+  serviceMethod: string
+  suggestedVehicles: string[]
+  waterDistance: string
+  outletNormal: number
+  constructionHelp: string
+  estimatedQuantity: string
+  estimatedHours: string
+  acceptanceReq: string
+  hasPreviousWork: number
+  acceptanceMethod: string
+  partyAContacts: { name: string; phone: string; position?: string }[]
+  remark: string
+
+  // 自动生成只读
+  taskNo: string
+  taskName: string
+  taskType: string
+}
+
+const acceptanceMethods = ['签署验收计量单', '累计自动计费']
+
+// 后台字典默认值兜底
+const fallbackConstructionTypes = [
+  { value: '疏通', label: '疏通' },
+  { value: '清淤', label: '清淤' },
+  { value: '排查', label: '排查' }
+]
+const fallbackConstructionFacilities = [
+  { value: 'PVC', label: 'PVC' },
+  { value: '直抽直排', label: '直抽直排' },
+  { value: '油污', label: '油污' },
+  { value: '排污', label: '排污' },
+  { value: '油污管', label: '油污管' },
+  { value: '化粪池', label: '化粪池' }
+]
+const fallbackServiceMethods = [
+  { value: '应急', label: '应急' },
+  { value: '月计划', label: '月计划' }
+]
+const fallbackSuggestedVehicles = [
+  { value: '疏通车', label: '疏通车' },
+  { value: '吸污车', label: '吸污车' },
+  { value: '高压清洗车', label: '高压清洗车' }
+]
+const fallbackWaterDistances = [
+  { value: '有消防水', label: '有消防水' },
+  { value: '无消防水', label: '无消防水' }
+]
+
+function mapConstructionTypeToTaskType(type?: string): string {
+  switch (type) {
+    case '疏通': return 'dredge'
+    case '清淤': return 'clean'
+    case '排查': return 'inspect'
+    default: return 'dredge'
+  }
+}
+
+function combineDateTime(date: string, time: string): string {
+  if (!date) return ''
+  return time ? `${date}T${time}` : `${date}T00:00:00`
+}
+
+function splitDateTime(value: string): { date: string; time: string } {
+  if (!value) return { date: '', time: '' }
+  const [date, time] = value.split('T')
+  return { date: date || '', time: time || '' }
+}
+
+function isRemoteUrl(url: string): boolean {
+  return /^https?:\/\//.test(url)
+}
+
+function generateTaskNo(): string {
+  return `TK-${Date.now()}`
+}
+
+function generateTaskName(
+  projectName: string,
+  servicePointName: string,
+  faultLocation: string,
+  constructionType: string
+): string {
+  const suffix = servicePointName || faultLocation || constructionType || '任务'
+  return `${projectName || ''}-${suffix}`.slice(0, 100)
+}
+
+export function usePublishTaskForm() {
+  const projectStore = useProjectStore()
+
+  // 字典
+  const constructionTypeDict = useDictOptions('construction_type', fallbackConstructionTypes)
+  const constructionFacilityDict = useDictOptions('construction_facility', fallbackConstructionFacilities)
+  const serviceMethodDict = useDictOptions('service_method', fallbackServiceMethods)
+  const suggestedVehicleDict = useDictOptions('suggested_vehicle', fallbackSuggestedVehicles)
+  const waterDistanceDict = useDictOptions('water_distance', fallbackWaterDistances)
+
+  // 表单
+  const form = ref<PublishTaskForm>({
+    projectId: undefined,
+    projectName: '',
+    servicePointId: undefined,
+    servicePointName: '',
+    cooperationType: '',
+    facilityType: '',
+    address: '',
+    contactName: '',
+    contactPhone: '',
+    contacts: [],
+
+    serviceTime: '',
+    faultLocation: '',
+    geoLocation: '',
+    latitude: '',
+    longitude: '',
+    faultDescription: '',
+    faultPhotos: [],
+    constructionType: '',
+    constructionFacility: '',
+    serviceMethod: '',
+    suggestedVehicles: [],
+    waterDistance: '',
+    outletNormal: 1,
+    constructionHelp: '',
+    estimatedQuantity: '',
+    estimatedHours: '',
+    acceptanceReq: '',
+    hasPreviousWork: 0,
+    acceptanceMethod: '',
+    partyAContacts: [],
+    remark: '',
+
+    taskNo: generateTaskNo(),
+    taskName: '',
+    taskType: ''
+  })
+
+  const servicePointOptions: Ref<ServicePoint[]> = ref([])
+  const submitting = ref(false)
+
+  // 计算属性
+  const projectNames = computed(() => [...projectStore.projects.map(p => p.projectName), '+ 新增项目'])
+  const projects = computed(() => projectStore.projects)
+  const selectedProject = computed(() => projectStore.projects.find(p => String(p.projectId) === String(form.value.projectId)) || null)
+
+  const projectIndex = computed(() => projects.value.findIndex(p => String(p.projectId) === String(form.value.projectId)))
+
+  const constructionTypeLabels = computed(() => constructionTypeDict.options.value.options.map(o => o.label))
+  const constructionFacilityLabels = computed(() => constructionFacilityDict.options.value.options.map(o => o.label))
+  const serviceMethodLabels = computed(() => serviceMethodDict.options.value.options.map(o => o.label))
+  const suggestedVehicleLabels = computed(() => suggestedVehicleDict.options.value.options.map(o => o.label))
+  const suggestedVehicleOptions = computed(() => suggestedVehicleDict.options.value.options)
+  const waterDistanceLabels = computed(() => waterDistanceDict.options.value.options.map(o => o.label))
+
+  const constructionTypeIndex = computed(() =>
+    constructionTypeDict.options.value.options.findIndex(o => o.value === form.value.constructionType))
+  const constructionFacilityIndex = computed(() =>
+    constructionFacilityDict.options.value.options.findIndex(o => o.value === form.value.constructionFacility))
+  const serviceMethodIndex = computed(() =>
+    serviceMethodDict.options.value.options.findIndex(o => o.value === form.value.serviceMethod))
+  const waterDistanceIndex = computed(() =>
+    waterDistanceDict.options.value.options.findIndex(o => o.value === form.value.waterDistance))
+  const acceptanceMethodIndex = computed(() =>
+    acceptanceMethods.indexOf(form.value.acceptanceMethod))
+
+  const servicePointNames = computed(() =>
+    servicePointOptions.value.map((p, idx) => p.name || `服务点${idx + 1}`))
+  // servicePointId 存储的是选项索引(服务点 JSON 无 id 字段)
+  const servicePointIndex = computed(() => {
+    const id = form.value.servicePointId
+    if (id === undefined) return -1
+    return servicePointOptions.value.findIndex((_, idx) => idx === id)
+  })
+
+  const selectedServicePointText = computed(() => form.value.servicePointName || '请选择服务点')
+
+  // 联动:施工类型变化 -> 任务类型
+  watch(() => form.value.constructionType, (val) => {
+    form.value.taskType = mapConstructionTypeToTaskType(val)
+  })
+
+  // 联动:故障位置 / 施工类型 / 服务点变化 -> 任务名称
+  watch(
+    () => [form.value.projectName, form.value.servicePointName, form.value.faultLocation, form.value.constructionType],
+    () => {
+      form.value.taskName = generateTaskName(
+        form.value.projectName,
+        form.value.servicePointName,
+        form.value.faultLocation,
+        form.value.constructionType
+      )
+    },
+    { deep: true }
+  )
+
+  // 初始化
+  onLoad(async (query) => {
+    await loadDictOptions()
+    if (projectStore.projects.length === 0) {
+      try { await projectStore.fetchProjects(true) } catch (e) { /* ignore */ }
+    }
+    if (query?.projectId) {
+      const project = projectStore.projects.find(p => String(p.projectId) === String(query.projectId))
+      if (project) {
+        selectProject(project)
+      }
+    }
+  })
+
+  async function loadDictOptions() {
+    await Promise.all([
+      constructionTypeDict.load(),
+      constructionFacilityDict.load(),
+      serviceMethodDict.load(),
+      suggestedVehicleDict.load(),
+      waterDistanceDict.load()
+    ])
+  }
+
+  function selectProject(project: Project) {
+    form.value.projectId = Number(project.projectId)
+    form.value.projectName = project.projectName || ''
+    form.value.cooperationType = getCooperationTypeText(project.cooperationType)
+    form.value.facilityType = parseJsonArray<string>(project.facilityTypes)
+      .map(t => getFacilityTypeText(t))
+      .join('、')
+    form.value.address = project.address || ''
+    form.value.contactName = project.primaryContactName || ''
+    form.value.contactPhone = project.primaryContactPhone || ''
+
+    servicePointOptions.value = parseJsonArray<ServicePoint>(project.servicePoints)
+    form.value.servicePointId = undefined
+    form.value.servicePointName = ''
+    form.value.contacts = []
+
+    generateTaskName(
+      form.value.projectName,
+      form.value.servicePointName,
+      form.value.faultLocation,
+      form.value.constructionType
+    )
+  }
+
+  function onProjectSelectChange(e: any) {
+    const index = Number(e.detail.value)
+    if (index >= projects.value.length) {
+      navigateTo('/subPackages/pages-common/addProject')
+      return
+    }
+    selectProject(projects.value[index])
+  }
+
+  function onServicePointChange(e: any) {
+    const index = Number(e.detail.value)
+    const point = servicePointOptions.value[index]
+    if (!point) return
+
+    form.value.servicePointId = point.id ?? index
+    form.value.servicePointName = point.name || `服务点${index + 1}`
+    form.value.address = point.address || form.value.address
+    form.value.contactName = point.contact || form.value.contactName
+    form.value.contactPhone = point.phone || form.value.contactPhone
+  }
+
+  function resetProjectSelection() {
+    form.value.projectId = undefined
+    form.value.projectName = ''
+    form.value.servicePointId = undefined
+    form.value.servicePointName = ''
+    form.value.cooperationType = ''
+    form.value.facilityType = ''
+    form.value.address = ''
+    form.value.contactName = ''
+    form.value.contactPhone = ''
+    form.value.contacts = []
+    servicePointOptions.value = []
+  }
+
+  function onConstructionTypeChange(e: any) {
+    const index = Number(e.detail.value)
+    const option = constructionTypeDict.options.value.options[index]
+    if (option) form.value.constructionType = option.value
+  }
+
+  function onConstructionFacilityChange(e: any) {
+    const index = Number(e.detail.value)
+    const option = constructionFacilityDict.options.value.options[index]
+    if (option) form.value.constructionFacility = option.value
+  }
+
+  function onServiceMethodChange(e: any) {
+    const index = Number(e.detail.value)
+    const option = serviceMethodDict.options.value.options[index]
+    if (option) form.value.serviceMethod = option.value
+  }
+
+  function onWaterDistanceChange(e: any) {
+    const index = Number(e.detail.value)
+    const option = waterDistanceDict.options.value.options[index]
+    if (option) form.value.waterDistance = option.value
+  }
+
+  function onAcceptanceMethodChange(e: any) {
+    const index = Number(e.detail.value)
+    form.value.acceptanceMethod = acceptanceMethods[index] || ''
+  }
+
+  function toggleVehicle(value: string) {
+    const idx = form.value.suggestedVehicles.indexOf(value)
+    if (idx > -1) {
+      form.value.suggestedVehicles.splice(idx, 1)
+    } else {
+      form.value.suggestedVehicles.push(value)
+    }
+  }
+
+  function chooseLocation() {
+    uni.authorize({
+      scope: 'scope.userLocation',
+      success: () => {
+        uni.chooseLocation({
+          success: (res) => {
+            form.value.geoLocation = res.address || res.name || ''
+            form.value.latitude = String(res.latitude ?? '')
+            form.value.longitude = String(res.longitude ?? '')
+          },
+          fail: () => {
+            uni.showToast({ title: '选择位置失败', icon: 'none' })
+          }
+        })
+      },
+      fail: () => {
+        uni.showModal({
+          title: '需要位置权限',
+          content: '请在设置中允许使用位置信息',
+          success: (res) => {
+            if (res.confirm) uni.openSetting()
+          }
+        })
+      }
+    })
+  }
+
+  // 联系人
+  function addContact() {
+    form.value.contacts.push({ name: '', phone: '' })
+  }
+
+  function removeContact(index: number) {
+    form.value.contacts.splice(index, 1)
+  }
+
+  function addPartyAContact() {
+    form.value.partyAContacts.push({ name: '', phone: '', position: '' })
+  }
+
+  function removePartyAContact(index: number) {
+    form.value.partyAContacts.splice(index, 1)
+  }
+
+  // 日期时间
+  const serviceTimeDate = computed({
+    get: () => splitDateTime(form.value.serviceTime).date,
+    set: (val: string) => {
+      const { time } = splitDateTime(form.value.serviceTime)
+      form.value.serviceTime = combineDateTime(val, time)
+    }
+  })
+
+  const serviceTimeTime = computed({
+    get: () => splitDateTime(form.value.serviceTime).time,
+    set: (val: string) => {
+      const { date } = splitDateTime(form.value.serviceTime)
+      form.value.serviceTime = combineDateTime(date, val)
+    }
+  })
+
+  function onServiceTimeDateChange(e: any) { serviceTimeDate.value = e.detail.value }
+  function onServiceTimeTimeChange(e: any) { serviceTimeTime.value = e.detail.value }
+
+  // 布尔选择
+  function setOutletNormal(value: number) {
+    form.value.outletNormal = value
+  }
+
+  function setHasPreviousWork(value: number) {
+    form.value.hasPreviousWork = value
+  }
+
+  function validateForm(): boolean {
+    if (!form.value.projectId) { showToast('请选择项目'); return false }
+    if (!form.value.address.trim()) { showToast('请输入项目地址'); return false }
+    if (!form.value.serviceTime) { showToast('请选择服务时间'); return false }
+    if (!form.value.faultLocation.trim()) { showToast('请输入故障位置'); return false }
+    if (!form.value.faultDescription.trim()) { showToast('请输入故障描述'); return false }
+    if (!form.value.constructionType) { showToast('请选择施工类型'); return false }
+    if (!form.value.constructionFacility) { showToast('请选择施工设施'); return false }
+    if (!form.value.serviceMethod) { showToast('请选择服务方式'); return false }
+    if (form.value.suggestedVehicles.length === 0) { showToast('请选择建议出车'); return false }
+    if (!form.value.acceptanceMethod) { showToast('请选择验收方式'); return false }
+    return true
+  }
+
+  function buildPayload(): TaskParams {
+    const project = selectedProject.value
+    const address = form.value.address.trim() || project?.address || '-'
+
+    return {
+      projectId: form.value.projectId!,
+      taskNo: generateTaskNo(),
+      taskName: form.value.taskName,
+      taskType: form.value.taskType,
+      status: 'auditing',
+      planDate: form.value.serviceTime.slice(0, 10),
+      address,
+      remark: form.value.remark,
+      facilityType: form.value.facilityType,
+      servicePointId: form.value.servicePointId,
+      servicePointName: form.value.servicePointName,
+      serviceContent: form.value.faultDescription,
+      faultLocation: form.value.faultLocation,
+      constructionType: form.value.constructionType,
+      constructionFacility: form.value.constructionFacility,
+      serviceMethod: form.value.serviceMethod,
+      waterDistance: form.value.waterDistance,
+      outletNormal: form.value.outletNormal,
+      assistantInfo: form.value.constructionHelp,
+      estimatedWorkload: form.value.estimatedQuantity,
+      estimatedDuration: form.value.estimatedHours,
+      acceptanceRequirement: form.value.acceptanceReq,
+      hasPreviousWork: form.value.hasPreviousWork,
+      acceptanceMethod: form.value.acceptanceMethod,
+      toolRequirement: form.value.suggestedVehicles.join(','),
+      expectedArrivalTime: form.value.serviceTime,
+      extraData: JSON.stringify({
+        projectName: project?.projectName,
+        servicePointName: form.value.servicePointName,
+        contactName: form.value.contactName,
+        contactPhone: form.value.contactPhone,
+        contacts: form.value.contacts,
+        partyAContacts: form.value.partyAContacts,
+        geoLocation: form.value.geoLocation,
+        latitude: form.value.latitude,
+        longitude: form.value.longitude,
+        faultPhotos: form.value.faultPhotos
+      })
+    }
+  }
+
+  async function submitTask() {
+    if (!validateForm()) return
+
+    if (form.value.faultPhotos.some((u) => !isRemoteUrl(u))) {
+      showToast('图片正在上传,请稍候')
+      return
+    }
+
+    submitting.value = true
+    try {
+      const payload = buildPayload()
+      await createTask(payload)
+      showToast({ title: '任务发布成功', icon: 'success' })
+      setTimeout(() => uni.navigateBack(), 1500)
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  return {
+    form,
+    submitting,
+    projectNames,
+    projectIndex,
+    selectedProject,
+    servicePointNames,
+    servicePointIndex,
+    selectedServicePointText,
+    constructionTypeLabels,
+    constructionTypeIndex,
+    constructionFacilityLabels,
+    constructionFacilityIndex,
+    serviceMethodLabels,
+    serviceMethodIndex,
+    suggestedVehicleLabels,
+    suggestedVehicleOptions,
+    waterDistanceLabels,
+    waterDistanceIndex,
+    acceptanceMethods,
+    acceptanceMethodIndex,
+    serviceTimeDate,
+    serviceTimeTime,
+
+    onProjectSelectChange,
+    onServicePointChange,
+    resetProjectSelection,
+    onConstructionTypeChange,
+    onConstructionFacilityChange,
+    onServiceMethodChange,
+    onWaterDistanceChange,
+    onAcceptanceMethodChange,
+    toggleVehicle,
+    chooseLocation,
+    addContact,
+    removeContact,
+    addPartyAContact,
+    removePartyAContact,
+    onServiceTimeDateChange,
+    onServiceTimeTimeChange,
+    setOutletNormal,
+    setHasPreviousWork,
+    submitTask
+  }
+}

+ 3 - 1
src/config/index.ts

@@ -2,7 +2,9 @@
 // 开发环境建议配置为后端所在电脑的局域网 IP,例如:http://192.168.1.100:8080/api
 // 真机调试/预览时 localhost 指向手机自身,无法访问电脑后端,必须改为局域网 IP
 // 生产环境通过构建时注入 VITE_APP_BASE_URL 覆盖
-const DEFAULT_BASE_URL = 'http://localhost:8080/api'
+const DEFAULT_BASE_URL = 'http://8.137.16.17:8000/api'
+// const DEFAULT_BASE_URL = 'http://localhost:8080/api'
+// const DEFAULT_BASE_URL = 'http://192.168.1.100:8080/api'
 
 export const BASE_URL = import.meta.env.VITE_APP_BASE_URL || DEFAULT_BASE_URL
 

+ 230 - 60
src/constants/sop.ts

@@ -11,24 +11,79 @@ export interface SopStepConfig {
   code: string
   /** 移动端展示标题 */
   title: string
-  /** 对应 admin 前端 PhotoPhase,用于照片分组互通 */
-  phase?: string
-  /** 是否可在该步骤上传照片 */
-  hasPhoto: boolean
+  /** 本步骤涉及的照片 phase 列表(与后台 PhotoUploadGroup 对齐) */
+  phases?: string[]
+  /** 本步骤是否需要输入文本 */
+  input?: {
+    label: string
+    placeholder: string
+    stateKey: 'blockageText' | 'feedbackText'
+  }
 }
 
 /** 8 步 SOP(与后台顺序一致) */
 export const SOP_STEPS: SopStepConfig[] = [
-  { step: 1, code: 'confirm', title: '确认任务', hasPhoto: false },
-  { step: 2, code: 'prepare', title: '出车准备', phase: 'departure_vehicle', hasPhoto: true },
-  { step: 3, code: 'arrive', title: '到达现场', phase: 'site_survey', hasPhoto: true },
-  { step: 4, code: 'work', title: '开始施工', phase: 'construction_process', hasPhoto: true },
-  { step: 5, code: 'photo', title: '施工完成', phase: 'construction_complete', hasPhoto: true },
-  { step: 6, code: 'clean', title: '现场清洗', phase: 'site_cleaning', hasPhoto: true },
-  { step: 7, code: 'accept', title: '验收签字', phase: 'acceptance', hasPhoto: true },
-  { step: 8, code: 'finish', title: '完工确认', hasPhoto: false },
+  { step: 1, code: 'confirm', title: '确认任务' },
+  { step: 2, code: 'prepare', title: '出车准备', phases: ['departure_vehicle'] },
+  { step: 3, code: 'arrive', title: '到达现场', phases: ['parking_sign', 'site_survey'] },
+  { step: 4, code: 'work', title: '开始施工', phases: ['blockage', 'construction_process'], input: { label: '堵塞物', placeholder: '如:头发、纸巾、油污等', stateKey: 'blockageText' } },
+  { step: 5, code: 'photo', title: '施工完成', phases: ['construction_complete'], input: { label: '设施反馈', placeholder: '设施状态、建议等', stateKey: 'feedbackText' } },
+  { step: 6, code: 'clean', title: '现场清洗', phases: ['site_cleaning'] },
+  { step: 7, code: 'accept', title: '验收签字', phases: ['acceptance'] },
+  { step: 8, code: 'finish', title: '完工确认' },
 ]
 
+/** 照片 phase 中文标签(与 admin 前端 PhotoUploadGroup 对齐) */
+export const PHASE_LABELS: Record<string, string> = {
+  departure_vehicle: '出车前车辆及设备',
+  parking_sign: '停车警示牌',
+  site_survey: '现场勘验',
+  blockage: '堵塞物照片',
+  construction_process: '施工过程',
+  construction_complete: '施工完成',
+  site_cleaning: '现场清洗',
+  acceptance: '验收确认',
+}
+
+/** 照片 phase 描述(与 admin 前端 PhotoUploadGroup 对齐) */
+export const PHASE_DESCRIPTIONS: Record<string, string> = {
+  departure_vehicle: '拍摄车辆外观、设备状态照片',
+  parking_sign: '拍摄现场停车警示牌摆放照片',
+  site_survey: '拍摄现场环境、管道状况照片或视频',
+  blockage: '拍摄堵塞物照片',
+  construction_process: '拍摄施工过程照片或视频',
+  construction_complete: '拍摄施工完成后的现场照片',
+  site_cleaning: '拍摄清洗后的现场照片',
+  acceptance: '拍摄验收签字或客户确认照片',
+}
+
+/** 照片 phase 最大数量(与 admin 前端 PhotoUploadGroup 对齐) */
+export const PHASE_MAX_COUNTS: Record<string, number> = {
+  departure_vehicle: 5,
+  parking_sign: 3,
+  site_survey: 10,
+  blockage: 5,
+  construction_process: 15,
+  construction_complete: 5,
+  site_cleaning: 5,
+  acceptance: 5,
+}
+
+/** 获取 phase 中文标签 */
+export function getPhaseLabel(phase: string): string {
+  return PHASE_LABELS[phase] || phase
+}
+
+/** 获取 phase 描述 */
+export function getPhaseDescription(phase: string): string {
+  return PHASE_DESCRIPTIONS[phase] || ''
+}
+
+/** 获取 phase 最大上传数量 */
+export function getPhaseMaxCount(phase: string): number {
+  return PHASE_MAX_COUNTS[phase] || 6
+}
+
 /** step 1-8 -> stepCode */
 export function getStepCode(step: number): string {
   return SOP_STEPS.find((s) => s.step === step)?.code || ''
@@ -52,58 +107,173 @@ export function getStepConfigByIndex(step: number): SopStepConfig | undefined {
 /** 步骤中文标题列表(用于流程条展示) */
 export const SOP_STEP_TITLES = SOP_STEPS.map((s) => s.title)
 
-// ==================== 步骤业务字段配置(与 admin 前端对齐) ====================
+// ==================== 与 admin 前端对齐的施工流程进度计算(任务详情页 + 任务卡片共享) ====================
 
-/** 出车准备检查项类型 */
-export type DepartureCheckCode = 'oil' | 'tire' | 'light' | 'hose' | 'tool'
+/** 顶部流程条 UI 步骤(与 admin FLOW_STEPS 对齐) */
+export const FLOW_STEPS = [
+  { uiStep: 1, title: '确认', backendCode: 'confirm' },
+  { uiStep: 2, title: '出车', backendCode: 'prepare' },
+  { uiStep: 3, title: '到达', backendCode: 'arrive' },
+  { uiStep: 4, title: '勘验', backendCode: 'arrive' },
+  { uiStep: 5, title: '施工', backendCode: 'work' },
+  { uiStep: 6, title: '完成', backendCode: 'photo' },
+  { uiStep: 7, title: '清洗', backendCode: 'clean' },
+  { uiStep: 8, title: '验收', backendCode: 'accept' },
+]
+
+/** 单 phase 配置 */
+export interface PhotoPhaseConfig {
+  phase: string
+  title: string
+  description: string
+  maxCount: number
+}
 
-/** 出车准备检查项配置 */
-export interface DepartureCheckItem {
-  code: DepartureCheckCode
-  name: string
-  description?: string
+/** 施工流程记录卡片配置(与 admin RECORD_STEPS 对齐) */
+export interface RecordStepDef {
+  uiStep: number
+  backendCode: string
+  title: string
+  description: string
+  phases: PhotoPhaseConfig[]
+  input?: {
+    label: string
+    placeholder: string
+    stateKey: 'blockageText' | 'feedbackText'
+  }
 }
 
-/** 出车准备检查项默认配置(与 DeparturePrepPanel 对齐) */
-export const DEFAULT_DEPARTURE_CHECKS: DepartureCheckItem[] = [
-  { code: 'oil', name: '机油检查', description: '检查机油液位是否正常,机油颜色是否正常' },
-  { code: 'tire', name: '轮胎检查', description: '检查轮胎气压、磨损情况、是否有异物' },
-  { code: 'light', name: '灯光检查', description: '检查大灯、转向灯、刹车灯是否正常' },
-  { code: 'hose', name: '水管检查', description: '检查水管连接是否牢固,有无漏水' },
-  { code: 'tool', name: '工具检查', description: '检查施工工具是否齐全、完好' },
+/** 7 张记录卡定义 */
+export const RECORD_STEPS: RecordStepDef[] = [
+  {
+    uiStep: 2,
+    backendCode: 'prepare',
+    title: '出车',
+    description: '出车前车辆及设备照片',
+    phases: [{ phase: 'departure_vehicle', title: '出车前车辆及设备', description: '拍摄车辆外观、设备状态照片', maxCount: getPhaseMaxCount('departure_vehicle') }],
+  },
+  {
+    uiStep: 3,
+    backendCode: 'arrive',
+    title: '到达现场',
+    description: '到达现场后停车警示牌照片',
+    phases: [{ phase: 'parking_sign', title: '停车警示牌', description: '到达现场后停车警示牌摆放照片', maxCount: getPhaseMaxCount('parking_sign') }],
+  },
+  {
+    uiStep: 4,
+    backendCode: 'arrive',
+    title: '现场勘验',
+    description: '现场勘验照片或视频',
+    phases: [{ phase: 'site_survey', title: '现场勘验', description: '拍摄现场环境、管道状况照片或视频', maxCount: getPhaseMaxCount('site_survey') }],
+  },
+  {
+    uiStep: 5,
+    backendCode: 'work',
+    title: '施工',
+    description: '施工过程照片或视频',
+    phases: [
+      { phase: 'blockage', title: '堵塞物照片', description: '拍摄堵塞物照片', maxCount: getPhaseMaxCount('blockage') },
+      { phase: 'construction_process', title: '施工过程', description: '拍摄施工过程照片或视频', maxCount: getPhaseMaxCount('construction_process') },
+    ],
+    input: { label: '堵塞物', placeholder: '如:头发、纸巾、油污等', stateKey: 'blockageText' },
+  },
+  {
+    uiStep: 6,
+    backendCode: 'photo',
+    title: '完成',
+    description: '施工完成后的现场照片',
+    phases: [{ phase: 'construction_complete', title: '施工完成', description: '拍摄施工完成后的现场照片', maxCount: getPhaseMaxCount('construction_complete') }],
+    input: { label: '设施反馈', placeholder: '设施状态、建议等', stateKey: 'feedbackText' },
+  },
+  {
+    uiStep: 7,
+    backendCode: 'clean',
+    title: '清洗',
+    description: '清洗后的现场照片',
+    phases: [{ phase: 'site_cleaning', title: '现场清洗', description: '拍摄清洗后的现场照片', maxCount: getPhaseMaxCount('site_cleaning') }],
+  },
+  {
+    uiStep: 8,
+    backendCode: 'accept',
+    title: '验收',
+    description: '验收签字或客户确认照片',
+    phases: [{ phase: 'acceptance', title: '验收确认', description: '拍摄验收签字或客户确认照片', maxCount: getPhaseMaxCount('acceptance') }],
+  },
 ]
 
-/** 单条检查结果 */
-export interface DepartureCheckResult {
-  code: DepartureCheckCode
-  passed: boolean
-  remark?: string
-}
-
-/** 现场踏勘结果(与 SiteSurveyPanel 对齐) */
-export interface SiteSurveyResult {
-  /** 到达时间 ISO 字符串 */
-  arriveTime?: string
-  /** 对接人状态 */
-  contactStatus?: 'arrived' | 'not_arrived'
-  /** 工单一致性 */
-  consistencyStatus?: 'consistent' | 'inconsistent'
-  /** 客户是否同意施工 */
-  customerAgreed?: boolean
-  /** 不一致说明 */
-  inconsistencyRemark?: string
-  /** 未到位说明 */
-  notArrivedRemark?: string
-}
-
-/** 各步骤需要收集的业务数据 */
-export interface StepFormData {
-  /** 出车准备检查结果 */
-  departureChecks?: DepartureCheckResult[]
-  /** 现场踏勘结果 */
-  siteSurvey?: SiteSurveyResult
-  /** 堵塞物情况 */
-  blockageText?: string
-  /** 设施反馈 */
-  feedbackText?: string
+/** 判断某记录步骤是否已完成:任一阶段上传了照片即视为完成(与 admin 对齐) */
+export function isRecordStepComplete(photos: { phase?: string; photoUrl?: string }[], uiStep: number): boolean {
+  const def = RECORD_STEPS.find((r) => r.uiStep === uiStep)
+  if (!def) return false
+  return def.phases.some((phaseConfig) =>
+    photos.some((p) => p.phase === phaseConfig.phase && p.photoUrl)
+  )
+}
+
+/** 已完成的记录步骤数量 */
+export function completedRecordStepCount(photos: { phase?: string; photoUrl?: string }[]): number {
+  return RECORD_STEPS.filter((r) => isRecordStepComplete(photos, r.uiStep)).length
+}
+
+/** 是否所有记录步骤都已完成 */
+export function isAllRecordStepsComplete(photos: { phase?: string; photoUrl?: string }[]): boolean {
+  return completedRecordStepCount(photos) === RECORD_STEPS.length
+}
+
+/** 判断流程步骤状态(与 admin TaskDetailPage getFlowStepState 对齐) */
+export function getFlowStepState(
+  task: { status?: string } | null | undefined,
+  photos: { phase?: string; photoUrl?: string }[],
+  uiStep: number
+): 'completed' | 'current' | 'pending' {
+  if (task?.status === 'completed') return 'completed'
+
+  if (uiStep === 1) {
+    return task?.status !== 'assigned' ? 'completed' : 'current'
+  }
+
+  const recordDef = RECORD_STEPS.find((r) => r.uiStep === uiStep)
+  if (recordDef && isRecordStepComplete(photos, recordDef.uiStep)) {
+    return 'completed'
+  }
+
+  const firstPendingIndex = FLOW_STEPS.findIndex((s) => {
+    if (s.uiStep === 1) return task?.status === 'assigned'
+    const rd = RECORD_STEPS.find((r) => r.uiStep === s.uiStep)
+    return rd ? !isRecordStepComplete(photos, rd.uiStep) : false
+  })
+  const isCurrent = firstPendingIndex >= 0 && FLOW_STEPS[firstPendingIndex].uiStep === uiStep
+  return isCurrent ? 'current' : 'pending'
+}
+
+/** 计算当前进行到的步骤索引(1-based,与 FLOW_STEPS 对齐) */
+export function computeCurrentStepIndex(
+  task: { status?: string; currentStep?: number } | null | undefined,
+  photos: { phase?: string; photoUrl?: string }[]
+): number {
+  if (task?.status === 'completed') return FLOW_STEPS.length
+  if (task?.status === 'cancelled' || task?.status === 'rejected') return 0
+
+  // 已确认/进行中任务:按照片记录卡完成情况推进
+  if (task?.status === 'in_progress' || task?.status === 'assigned') {
+    const completed = completedRecordStepCount(photos)
+    return Math.min(completed + 1, FLOW_STEPS.length)
+  }
+
+  // 其他状态回退到后台 currentStep 映射
+  const step = Number(task?.currentStep)
+  if (step > 0) {
+    return Math.min(step, FLOW_STEPS.length)
+  }
+  return 1
+}
+
+/** 流程条进度百分比(0-100) */
+export function computeFlowProgress(
+  task: { status?: string } | null | undefined,
+  photos: { phase?: string; photoUrl?: string }[]
+): number {
+  if (task?.status === 'completed') return 100
+  const completedCount = FLOW_STEPS.filter((s) => getFlowStepState(task, photos, s.uiStep) === 'completed').length - 1
+  return Math.max(0, (completedCount / (FLOW_STEPS.length - 1)) * 100)
 }

+ 1 - 1
src/custom-tab-bar/index.js

@@ -6,7 +6,7 @@ const commonTabs = [
 
 const dispatchTabs = [
   { pagePath: 'pages/home/home', text: '首页', icon: '/static/tabbar/home.png', activeIcon: '/static/tabbar/home-active.png' },
-  { pagePath: 'pages/schedule/schedule', text: '排班', icon: '/static/tabbar/排班.png', activeIcon: '/static/tabbar/排班-active.png' },
+  { pagePath: 'pages/schedule/schedule', text: '排班', icon: '/static/tabbar/schedule.png', activeIcon: '/static/tabbar/schedule-active.png' },
   { pagePath: 'pages/task/task', text: '任务', icon: '/static/tabbar/task.png', activeIcon: '/static/tabbar/task-active.png' },
   { pagePath: 'pages/my/my', text: '我的', icon: '/static/tabbar/my.png', activeIcon: '/static/tabbar/my-active.png' },
 ]

+ 7 - 5
src/manifest.json

@@ -1,6 +1,6 @@
 {
-    "name" : "清道夫App",
-    "appid" : "__UNI__QINGDAOFU",
+    "name" : "绿水青山",
+    "appid" : "__UNI__F00818B",
     "description" : "清道夫系统 - 环境服务管理平台",
     "versionName" : "1.0.0",
     "versionCode" : "100",
@@ -41,7 +41,9 @@
                 ]
             },
             /* ios打包配置 */
-            "ios" : {},
+            "ios" : {
+                "dSYMs" : false
+            },
             /* SDK配置 */
             "sdkConfigs" : {}
         }
@@ -81,8 +83,8 @@
     "mp-toutiao" : {
         "usingComponents" : true
     },
-    "uniStatistics": {  
-        "enable": false
+    "uniStatistics" : {
+        "enable" : false
     },
     "vueVersion" : "3"
 }

+ 12 - 4
src/pages.json

@@ -59,19 +59,27 @@
     "list": [
       {
         "pagePath": "pages/home/home",
-        "text": "首页"
+        "text": "首页",
+        "iconPath": "static/tabbar/home.png",
+        "selectedIconPath": "static/tabbar/home-active.png"
       },
       {
         "pagePath": "pages/task/task",
-        "text": "任务"
+        "text": "任务",
+        "iconPath": "static/tabbar/task.png",
+        "selectedIconPath": "static/tabbar/task-active.png"
       },
       {
         "pagePath": "pages/schedule/schedule",
-        "text": "排班"
+        "text": "排班",
+        "iconPath": "static/tabbar/schedule.png",
+        "selectedIconPath": "static/tabbar/schedule-active.png"
       },
       {
         "pagePath": "pages/my/my",
-        "text": "我的"
+        "text": "我的",
+        "iconPath": "static/tabbar/my.png",
+        "selectedIconPath": "static/tabbar/my-active.png"
       }
     ]
   },

+ 13 - 1
src/pages/home/home.vue

@@ -1,8 +1,11 @@
 <template>
-  <view class="app-container">
+  <view class="app-container pb-16">
     <SalesHome v-if="role === 'sales'" />
     <DispatchHome v-else-if="role === 'dispatch'" />
     <ConstructionHome v-else />
+    <!-- #ifdef H5 || APP-PLUS -->
+    <AppTabBar />
+    <!-- #endif -->
   </view>
 </template>
 
@@ -10,16 +13,25 @@
 import { onShow } from '@dcloudio/uni-app'
 import { computed } from 'vue'
 import { useAuthStore } from '@/stores/auth'
+import { safeHideTabBar } from '@/utils'
 import SalesHome from '@/components/home/SalesHome.vue'
 import DispatchHome from '@/components/home/DispatchHome.vue'
 import ConstructionHome from '@/components/home/ConstructionHome.vue'
+// #ifdef H5 || APP-PLUS
+import AppTabBar from '@/components/common/AppTabBar.vue'
+// #endif
 
 const authStore = useAuthStore()
 const role = computed(() => authStore.userRole || 'sales')
 
 onShow(() => {
+  // #ifdef H5 || APP-PLUS
+  safeHideTabBar({ animation: false })
+  // #endif
+  // #ifdef MP-WEIXIN
   const pages = getCurrentPages()
   const page = pages[pages.length - 1] as any
   page?.getTabBar?.()?.setSelected?.(0)
+  // #endif
 })
 </script>

+ 13 - 1
src/pages/my/my.vue

@@ -1,21 +1,33 @@
 <template>
-  <view class="app-container">
+  <view class="app-container pb-16">
     <ProfileView />
+    <!-- #ifdef H5 || APP-PLUS -->
+    <AppTabBar />
+    <!-- #endif -->
   </view>
 </template>
 
 <script setup lang="ts">
 import { onShow } from '@dcloudio/uni-app'
 import { useAuthStore } from '@/stores/auth'
+import { safeHideTabBar } from '@/utils'
 import ProfileView from '@/components/my/ProfileView.vue'
+// #ifdef H5 || APP-PLUS
+import AppTabBar from '@/components/common/AppTabBar.vue'
+// #endif
 
 const authStore = useAuthStore()
 
 onShow(() => {
+  // #ifdef H5 || APP-PLUS
+  safeHideTabBar({ animation: false })
+  // #endif
+  // #ifdef MP-WEIXIN
   const pages = getCurrentPages()
   const page = pages[pages.length - 1] as any
   const role = authStore.userRole
   const index = role === 'dispatch' ? 3 : 2
   page?.getTabBar?.()?.setSelected?.(index)
+  // #endif
 })
 </script>

+ 15 - 1
src/pages/schedule/schedule.vue

@@ -1,14 +1,28 @@
 <template>
-  <ScheduleView :show-top-bar="false" :show-back="false" :show-status-bar="true" />
+  <view class="app-container pb-16">
+    <ScheduleView :show-top-bar="false" :show-back="false" :show-status-bar="true" />
+    <!-- #ifdef H5 || APP-PLUS -->
+    <AppTabBar />
+    <!-- #endif -->
+  </view>
 </template>
 
 <script setup lang="ts">
 import { onShow } from '@dcloudio/uni-app'
+import { safeHideTabBar } from '@/utils'
 import ScheduleView from '@/components/dispatch/ScheduleView.vue'
+// #ifdef H5 || APP-PLUS
+import AppTabBar from '@/components/common/AppTabBar.vue'
+// #endif
 
 onShow(() => {
+  // #ifdef H5 || APP-PLUS
+  safeHideTabBar({ animation: false })
+  // #endif
+  // #ifdef MP-WEIXIN
   const pages = getCurrentPages()
   const page = pages[pages.length - 1] as any
   page?.getTabBar?.()?.setSelected?.(1)
+  // #endif
 })
 </script>

+ 13 - 1
src/pages/task/task.vue

@@ -1,6 +1,9 @@
 <template>
-  <view class="app-container task-page">
+  <view class="app-container task-page pb-16">
     <TaskListView ref="taskListRef" :role="role" />
+    <!-- #ifdef H5 || APP-PLUS -->
+    <AppTabBar />
+    <!-- #endif -->
   </view>
 </template>
 
@@ -9,7 +12,11 @@ import { ref, computed } from 'vue'
 import { onLoad, onShow, onPullDownRefresh } from '@dcloudio/uni-app'
 import { useAuthStore } from '@/stores/auth'
 import { useTaskStore } from '@/stores/task'
+import { safeHideTabBar } from '@/utils'
 import TaskListView from '@/components/task/TaskListView.vue'
+// #ifdef H5 || APP-PLUS
+import AppTabBar from '@/components/common/AppTabBar.vue'
+// #endif
 
 const authStore = useAuthStore()
 const taskStore = useTaskStore()
@@ -30,11 +37,16 @@ onLoad(() => {
 
 onShow(() => {
   loadTasks()
+  // #ifdef H5 || APP-PLUS
+  safeHideTabBar({ animation: false })
+  // #endif
+  // #ifdef MP-WEIXIN
   const pages = getCurrentPages()
   const page = pages[pages.length - 1] as any
   const role = authStore.userRole
   const index = role === 'dispatch' ? 2 : 1
   page?.getTabBar?.()?.setSelected?.(index)
+  // #endif
 })
 
 onPullDownRefresh(async () => {

+ 0 - 0
src/static/tabbar/合同.png → src/static/tabbar/contract.png


+ 0 - 0
src/static/tabbar/客户.png → src/static/tabbar/customer.png


+ 0 - 0
src/static/tabbar/项目.png → src/static/tabbar/project.png


+ 0 - 0
src/static/tabbar/排班-active.png → src/static/tabbar/schedule-active.png


+ 0 - 0
src/static/tabbar/排班.png → src/static/tabbar/schedule.png


+ 12 - 15
src/stores/auth.ts

@@ -16,7 +16,7 @@ export const useAuthStore = defineStore('auth', () => {
     homePath?: string
     menus?: LoginMenu[]
     permissions?: string[]
-    endpoints?: string[]
+    mpEndpoint?: 'mp-sales' | 'mp-dispatch' | 'mp-construction'
   } | null>(null)
 
   // Getters
@@ -25,28 +25,25 @@ export const useAuthStore = defineStore('auth', () => {
   const userRole = computed(() => user.value?.role || '')
   const userInfo = computed(() => user.value)
   const homePath = computed(() => roleInfo.value?.homePath || '')
-  const userEndpoints = computed(() => roleInfo.value?.endpoints || [])
+  const userMpEndpoint = computed(() => roleInfo.value?.mpEndpoint || null)
 
-  // 由账号绑定的可登录端点推导所属业务端
-  const endpointToRole = (endpoints?: string[]): UserRole | null => {
+  // 由账号绑定的移动端登录端推导所属业务端
+  const endpointToRole = (mpEndpoint?: string): UserRole | null => {
     const map: Record<string, UserRole> = {
       'mp-sales': 'sales',
       'mp-dispatch': 'dispatch',
       'mp-construction': 'construction',
     }
-    for (const code of endpoints || []) {
-      if (map[code]) return map[code]
-    }
-    return null
+    return mpEndpoint ? map[mpEndpoint] || null : null
   }
 
   // Actions
   async function doLogin(
-    username: string,
+    account: string,
     password: string,
     selectedRole?: 'mp-sales' | 'mp-dispatch' | 'mp-construction'
   ) {
-    const res = await login({ username, password, roleType: selectedRole })
+    const res = await login({ account, password, roleType: selectedRole })
 
     let teamName = ''
     try {
@@ -57,10 +54,10 @@ export const useAuthStore = defineStore('auth', () => {
       teamName = ''
     }
 
-    const finalRole = endpointToRole(res.endpoints)
+    const finalRole = endpointToRole(res.mpEndpoint)
     if (!finalRole) {
-      uni.showToast({ title: '当前账号未配置可登录端点,无法登录', icon: 'none' })
-      throw new Error('当前账号未配置可登录端点')
+      uni.showToast({ title: '当前账号未配置移动端登录端,无法登录', icon: 'none' })
+      throw new Error('当前账号未配置移动端登录端')
     }
 
     const userData: User = {
@@ -93,7 +90,7 @@ export const useAuthStore = defineStore('auth', () => {
       homePath: res.homePath,
       menus: res.menus,
       permissions: res.permissions,
-      endpoints: res.endpoints,
+      mpEndpoint: res.mpEndpoint,
     }
 
     user.value = userData
@@ -155,7 +152,7 @@ export const useAuthStore = defineStore('auth', () => {
     userRole,
     userInfo,
     homePath,
-    userEndpoints,
+    userMpEndpoint,
     doLogin,
     doLogout,
     logout,

+ 42 - 0
src/stores/dict.ts

@@ -0,0 +1,42 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { getDictTree, buildDictOptions } from '../api/dict'
+
+export const useDictStore = defineStore('dict', () => {
+  const maps = ref<Record<string, Record<string, string>>>({})
+
+  async function loadDict(type: string) {
+    if (maps.value[type]) return
+    try {
+      const data = await getDictTree(type)
+      const opts = buildDictOptions(data)
+      maps.value[type] = opts.labelMap
+    } catch {
+      maps.value[type] = {}
+    }
+  }
+
+  function getLabel(type: string, value: string): string {
+    return maps.value[type]?.[value] || value
+  }
+
+  /** 加载任务/项目展示常用的字典映射 */
+  async function loadCommonDicts() {
+    await Promise.all([
+      loadDict('facility_type'),
+      loadDict('construction_facility'),
+      loadDict('construction_type'),
+      loadDict('service_method'),
+      loadDict('suggested_vehicle'),
+      loadDict('water_distance'),
+      loadDict('acceptance_method'),
+    ])
+  }
+
+  return {
+    maps,
+    loadDict,
+    loadCommonDicts,
+    getLabel,
+  }
+})

+ 57 - 2
src/stores/vehicle.ts

@@ -1,11 +1,53 @@
 import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
 import type { Vehicle, VehicleStatus } from '@/types'
-import { mockVehicles } from '@/mock'
+import { getVehicleList } from '@/api/vehicle'
+
+interface VehicleRow {
+  vehicleId?: number | string
+  plateNo?: string
+  vehicleType?: string
+  status?: string
+  driverName?: string
+  driverPhone?: string
+  size?: string
+  loadCapacity?: string
+  capacity?: string
+  purchaseDate?: string
+  mileage?: number
+}
+
+function mapVehicleStatus(status?: string): VehicleStatus {
+  const map: Record<string, VehicleStatus> = {
+    idle: 'available',
+    available: 'available',
+    in_use: 'in_use',
+    working: 'in_use',
+    maintenance: 'maintenance',
+  }
+  return map[status || ''] || 'available'
+}
+
+function mapVehicleRow(row: VehicleRow): Vehicle {
+  return {
+    id: String(row.vehicleId ?? ''),
+    plateNumber: row.plateNo || '',
+    type: row.vehicleType || '',
+    status: mapVehicleStatus(row.status),
+    driver: row.driverName || undefined,
+    driverPhone: row.driverPhone || undefined,
+    size: row.size,
+    loadCapacity: row.loadCapacity,
+    capacity: row.capacity,
+    purchaseDate: row.purchaseDate,
+    mileage: row.mileage,
+  }
+}
 
 export const useVehicleStore = defineStore('vehicle', () => {
-  const vehicles = ref<Vehicle[]>([...mockVehicles])
+  const vehicles = ref<Vehicle[]>([])
   const filterStatus = ref<VehicleStatus | 'all'>('all')
+  const loading = ref(false)
 
   const filteredVehicles = computed(() => {
     if (filterStatus.value === 'all') return vehicles.value
@@ -16,6 +58,17 @@ export const useVehicleStore = defineStore('vehicle', () => {
   const inUseCount = computed(() => vehicles.value.filter((v) => v.status === 'in_use').length)
   const maintenanceCount = computed(() => vehicles.value.filter((v) => v.status === 'maintenance').length)
 
+  async function fetchVehicles() {
+    loading.value = true
+    try {
+      const res: any = await getVehicleList(1, 100)
+      const rows: VehicleRow[] = res.rows || []
+      vehicles.value = rows.map(mapVehicleRow)
+    } finally {
+      loading.value = false
+    }
+  }
+
   function setFilter(status: VehicleStatus | 'all') {
     filterStatus.value = status
   }
@@ -34,6 +87,8 @@ export const useVehicleStore = defineStore('vehicle', () => {
     availableVehicles,
     inUseCount,
     maintenanceCount,
+    loading,
+    fetchVehicles,
     setFilter,
     updateVehicleStatus,
   }

+ 814 - 355
src/subPackages/pages-common/addProject.vue

@@ -1,258 +1,299 @@
 <template>
-  <view class="app-container detail-page pb-24">
+  <view class="app-container detail-page">
     <TopBar :title="mode === 'edit' ? '编辑项目' : '新增项目'" show-back />
 
     <view v-if="loadingDetail" class="py-20 text-center">
       <text class="text-gray-400">加载中...</text>
     </view>
 
-    <view v-else class="px-4 py-4 gap-y-4">
+    <view v-else class="form-body">
       <!-- 关联合同 -->
-      <view class="card p-4">
-        <view class="detail-section-title mb-4">
-          <view class="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center mr-3">
-            <text class="uni-icons uniui-wallet-filled text-green-500"></text>
+      <view class="card">
+        <view class="section-title">
+          <view class="section-icon section-icon--green">
+            <text class="uni-icons uniui-wallet-filled"></text>
           </view>
-          <text>关联合同</text>
+          <text class="section-text">关联合同</text>
         </view>
 
-        <view class="gap-y-4">
-          <view>
-            <text class="form-label">
-              选择合同
-              <text class="text-red-500">*</text>
-            </text>
-            <picker mode="selector" :range="contractLabels" :value="selectedContractIndex" @change="onContractChange">
-              <view class="form-select flex items-center justify-between" :class="{ 'text-gray-400': selectedContractIndex < 0 }">
-                <text class="truncate flex-1">{{ selectedContractIndex >= 0 ? contractLabels[selectedContractIndex] : '请选择合同' }}</text>
-                <text class="uni-icons uniui-arrowdown text-gray-400 ml-2"></text>
-              </view>
-            </picker>
-            <text v-if="errors.contractId" class="text-red-500 text-xs mt-1">{{ errors.contractId }}</text>
-          </view>
-
-          <view v-if="selectedContract" class="bg-gray-50 rounded-xl p-3 gap-y-2">
-            <view class="flex justify-between text-sm">
-              <text class="text-gray-500">客户</text>
-              <text class="text-gray-800">{{ form.customerName || '-' }}</text>
-            </view>
-            <view class="flex justify-between text-sm">
-              <text class="text-gray-500">主联系人</text>
-              <text class="text-gray-800">{{ form.primaryContactName || '-' }} {{ form.primaryContactPhone || '' }}</text>
-            </view>
-            <view class="flex justify-between text-sm">
-              <text class="text-gray-500">合同金额</text>
-              <text class="text-gray-800">{{ formatMoney(form.contractAmount) }}</text>
-            </view>
-            <view class="flex justify-between text-sm">
-              <text class="text-gray-500">付款方式</text>
-              <text class="text-gray-800">{{ getPaymentMethodText(form.paymentMethod || '') }}</text>
-            </view>
-            <view class="flex justify-between text-sm">
-              <text class="text-gray-500">项目地址</text>
-              <text class="text-gray-800 flex-1 text-right ml-3">{{ derivedAddress || '-' }}</text>
+        <view class="form-group">
+          <text class="form-label">选择合同</text>
+          <picker mode="selector" :range="contractLabels" :value="selectedContractIndex" @change="onContractChange">
+            <view class="form-select" :class="{ 'form-select--empty': selectedContractIndex < 0 }">
+              <text class="truncate flex-1">{{ selectedContractIndex >= 0 ? contractLabels[selectedContractIndex] : '请选择合同(选填)' }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400 ml-2"></text>
             </view>
+          </picker>
+        </view>
+
+        <view v-if="selectedContract" class="info-panel">
+          <view class="info-row">
+            <text class="info-label">客户</text>
+            <text class="info-value">{{ form.customerName || '-' }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">主联系人</text>
+            <text class="info-value">{{ form.primaryContactName || '-' }} {{ form.primaryContactPhone || '' }}</text>
           </view>
         </view>
       </view>
 
       <!-- 基本信息 -->
-      <view class="card p-4">
-        <view class="detail-section-title mb-4">
-          <view class="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center mr-3">
-            <text class="uni-icons uniui-folder-add-filled text-primary"></text>
+      <view class="card">
+        <view class="section-title">
+          <view class="section-icon section-icon--blue">
+            <text class="uni-icons uniui-folder-add-filled"></text>
           </view>
-          <text>基本信息</text>
+          <text class="section-text">基本信息</text>
         </view>
 
-        <view class="gap-y-4">
-          <view>
-            <text class="form-label">
-              项目名称
-              <text class="text-red-500">*</text>
-            </text>
-            <uni-easyinput class="form-input" placeholder="请输入项目名称" v-model="form.projectName" @blur="validateField('projectName')" />
-            <text v-if="errors.projectName" class="text-red-500 text-xs mt-1">{{ errors.projectName }}</text>
-          </view>
+        <view class="form-group">
+          <text class="form-label">
+            项目名称
+            <text class="required">*</text>
+          </text>
+          <uni-easyinput class="form-input" placeholder="请输入项目名称" v-model="form.projectName" @blur="validateField('projectName')" />
+          <text v-if="errors.projectName" class="error-text">{{ errors.projectName }}</text>
+        </view>
 
-          <view>
-            <text class="form-label">
-              合作类型
-              <text class="text-red-500">*</text>
-            </text>
-            <picker mode="selector" :range="cooperationLabels" :value="cooperationIndex" @change="onCooperationChange">
-              <view class="form-select flex items-center" :class="{ 'text-gray-400': !form.cooperationType }">
-                <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
-                {{ form.cooperationType ? getCooperationTypeText(form.cooperationType) : '请选择合作类型' }}
-              </view>
-            </picker>
-            <text v-if="errors.cooperationType" class="text-red-500 text-xs mt-1">{{ errors.cooperationType }}</text>
-          </view>
+        <view class="form-group">
+          <text class="form-label">
+            合作类型
+            <text class="required">*</text>
+          </text>
+          <picker mode="selector" :range="cooperationLabels" :value="cooperationIndex" @change="onCooperationChange">
+            <view class="form-select" :class="{ 'form-select--empty': !form.cooperationType }">
+              <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
+              <text class="flex-1">{{ form.cooperationType ? getCooperationTypeText(form.cooperationType) : '请选择合作类型' }}</text>
+            </view>
+          </picker>
+          <text v-if="errors.cooperationType" class="error-text">{{ errors.cooperationType }}</text>
+        </view>
 
-          <view>
-            <text class="form-label">
-              所属行业
-              <text class="text-red-500">*</text>
-            </text>
-            <picker mode="selector" :range="industryLabels" :value="industryIndex" @change="onIndustryChange">
-              <view class="form-select flex items-center" :class="{ 'text-gray-400': !form.industry }">
-                <text class="uni-icons uniui-arrowdown text-gray-400 mr-2"></text>
-                {{ form.industry ? getIndustryText(form.industry) : '请选择所属行业' }}
-              </view>
-            </picker>
-            <text v-if="errors.industry" class="text-red-500 text-xs mt-1">{{ errors.industry }}</text>
+        <view class="form-group">
+          <text class="form-label">
+            设施类型
+            <text class="required">*</text>
+            <text class="form-hint">可多选</text>
+          </text>
+          <view class="chip-group">
+            <view
+              v-for="ft in facilityTypeOptions"
+              :key="ft.value"
+              class="chip"
+              :class="form.facilityTypes.includes(ft.value) ? 'chip--active' : 'chip--default'"
+              @click="toggleFacility(ft.value)"
+            >
+              {{ ft.label }}
+            </view>
           </view>
+          <text v-if="errors.facilityTypes" class="error-text">{{ errors.facilityTypes }}</text>
+        </view>
 
-          <view>
-            <text class="form-label">
-              设施类型
-              <text class="text-red-500">*</text>
-              <text class="text-gray-400 text-xs ml-2">可多选</text>
-            </text>
-            <view class="flex flex-wrap gap-2 mt-1">
-              <view
-                v-for="ft in facilityTypes"
-                :key="ft.value"
-                class="px-3 py-2 rounded-xl text-sm border"
-                :class="form.facilityTypes.includes(ft.value) ? 'bg-primary text-white border-primary' : 'bg-gray-50 text-gray-600 border-gray-200'"
-                @click="toggleFacility(ft.value)"
-              >
-                {{ ft.label }}
-              </view>
+        <view class="form-group">
+          <text class="form-label">
+            项目地址
+            <text class="required">*</text>
+          </text>
+          <view class="flex items-center gap-2">
+            <uni-easyinput
+              class="form-input flex-1"
+              placeholder="请输入项目地址或点击地图选点"
+              v-model="form.address"
+              @blur="validateField('address')"
+            />
+            <view class="map-btn" @click="chooseLocation">
+              <text class="uni-icons uniui-location-filled map-btn-icon"></text>
+              <text class="map-btn-text">地图</text>
             </view>
-            <text v-if="errors.facilityTypes" class="text-red-500 text-xs mt-1">{{ errors.facilityTypes }}</text>
           </view>
+          <text v-if="errors.address" class="error-text">{{ errors.address }}</text>
+          <text v-if="form.latitude && form.longitude" class="geo-text">
+            <text class="uni-icons uniui-location-filled mr-1"></text>
+            {{ form.latitude }}, {{ form.longitude }}
+          </text>
+        </view>
 
-          <view class="grid grid-cols-2 gap-3">
-            <view>
-              <text class="form-label">
-                入住率(%)
-                <text class="text-red-500">*</text>
-              </text>
-              <uni-easyinput class="form-input" placeholder="如 90" v-model="form.occupancyRate" type="digit" @blur="validateField('occupancyRate')" />
-              <text v-if="errors.occupancyRate" class="text-red-500 text-xs mt-1">{{ errors.occupancyRate }}</text>
-            </view>
-            <view>
-              <text class="form-label">
-                建筑面积(㎡)
-                <text class="text-red-500">*</text>
-              </text>
-              <uni-easyinput class="form-input" placeholder="如 12000" v-model="form.buildingArea" type="digit" @blur="validateField('buildingArea')" />
-              <text v-if="errors.buildingArea" class="text-red-500 text-xs mt-1">{{ errors.buildingArea }}</text>
+        <view class="form-group">
+          <text class="form-label">
+            位置联系人
+            <text class="required">*</text>
+          </text>
+          <view v-if="locationContacts.length > 0" class="contact-list">
+            <view
+              v-for="(contact, index) in locationContacts"
+              :key="index"
+              class="contact-card"
+            >
+              <view class="contact-header">
+                <text class="contact-index">联系人 {{ index + 1 }}</text>
+                <view class="contact-delete" @click="removeLocationContact(index)">
+                  <text class="uni-icons uniui-trash text-sm mr-1"></text>
+                  <text>删除</text>
+                </view>
+              </view>
+              <view class="contact-fields">
+                <view class="contact-field">
+                  <text class="contact-field-label">姓名</text>
+                  <input v-model="contact.name" class="contact-field-input" placeholder="请输入姓名" @blur="validateField('locationContacts')" />
+                </view>
+                <view class="contact-field">
+                  <text class="contact-field-label">电话</text>
+                  <input v-model="contact.phone" class="contact-field-input" placeholder="请输入手机号" maxlength="11" @blur="validateField('locationContacts')" />
+                </view>
+              </view>
             </view>
           </view>
-
-          <view>
-            <text class="form-label">项目介绍</text>
-            <textarea
-              v-model="form.projectDescription"
-              class="w-full p-3 bg-surface rounded-xl text-sm text-gray-700"
-              placeholder="请输入项目介绍..."
-              :maxlength="500"
-            />
+          <view class="add-btn" @click="addLocationContact">
+            <text class="uni-icons uniui-plus text-primary mr-1"></text>
+            <text class="text-primary text-sm">添加联系人</text>
           </view>
+          <text v-if="errors.locationContacts" class="error-text">{{ errors.locationContacts }}</text>
+        </view>
+
+        <view class="form-group mb-0">
+          <text class="form-label">项目介绍</text>
+          <textarea
+            v-model="form.projectDescription"
+            class="form-textarea"
+            placeholder="请输入项目介绍..."
+            :maxlength="500"
+          />
         </view>
       </view>
 
-      <!-- 合同日期 -->
-      <view class="card p-4">
-        <view class="detail-section-title mb-4">
-          <view class="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center mr-3">
-            <text class="uni-icons uniui-calendar text-purple-500"></text>
+      <!-- 服务有效期 -->
+      <view class="card">
+        <view class="section-title">
+          <view class="section-icon section-icon--purple">
+            <text class="uni-icons uniui-calendar"></text>
           </view>
-          <text>合同日期</text>
+          <text class="section-text">服务有效期</text>
         </view>
-        <view class="grid grid-cols-2 gap-3">
-          <view>
-            <text class="form-label">合同开始</text>
-            <picker mode="date" :value="form.contractStartDate" @change="onStartDateChange">
-              <view class="form-select flex items-center text-gray-700">
+        <view class="date-range">
+          <view class="date-field">
+            <text class="form-label">开始日期</text>
+            <picker mode="date" :value="form.serviceStartDate" @change="onServiceStartDateChange">
+              <view class="form-select" :class="{ 'form-select--empty': !form.serviceStartDate }">
                 <text class="uni-icons uniui-calendar text-gray-400 mr-2"></text>
-                {{ form.contractStartDate || '选择日期' }}
+                <text>{{ form.serviceStartDate || '选择日期' }}</text>
               </view>
             </picker>
           </view>
-          <view>
-            <text class="form-label">合同结束</text>
-            <picker mode="date" :value="form.contractEndDate" @change="onEndDateChange">
-              <view class="form-select flex items-center text-gray-700">
+          <view class="date-divider">至</view>
+          <view class="date-field">
+            <text class="form-label">结束日期</text>
+            <picker mode="date" :value="form.serviceEndDate" @change="onServiceEndDateChange">
+              <view class="form-select" :class="{ 'form-select--empty': !form.serviceEndDate }">
                 <text class="uni-icons uniui-calendar text-gray-400 mr-2"></text>
-                {{ form.contractEndDate || '选择日期' }}
+                <text>{{ form.serviceEndDate || '选择日期' }}</text>
               </view>
             </picker>
           </view>
         </view>
       </view>
 
+      <!-- 费用信息 -->
+      <view class="card">
+        <view class="section-title">
+          <view class="section-icon section-icon--yellow">
+            <text class="uni-icons uniui-wallet-filled"></text>
+          </view>
+          <text class="section-text">费用信息</text>
+        </view>
+
+        <view class="form-group mb-0">
+          <text class="form-label">合同总金额(元)</text>
+          <uni-easyinput
+            class="form-input"
+            type="digit"
+            placeholder="请输入合同总金额"
+            v-model="form.contractAmount"
+          />
+        </view>
+      </view>
+
       <!-- 服务点信息 -->
-      <view class="card p-4">
-        <view class="detail-section-title mb-4">
-          <view class="w-8 h-8 rounded-lg bg-cyan-100 flex items-center justify-center mr-3">
-            <text class="uni-icons uniui-location-filled text-cyan-500"></text>
+      <view class="card">
+        <view class="section-title">
+          <view class="section-icon section-icon--cyan">
+            <text class="uni-icons uniui-location-filled"></text>
           </view>
-          <text>服务点与服务项</text>
+          <text class="section-text">服务点与服务项</text>
         </view>
 
-        <view class="gap-y-4">
+        <view v-if="form.servicePoints.length > 0" class="service-point-list">
           <view
             v-for="(point, index) in form.servicePoints"
             :key="index"
-            class="border border-gray-200 rounded-xl p-4"
+            class="service-point-card"
           >
-            <view class="flex justify-between items-center mb-3">
-              <text class="text-sm font-semibold text-gray-700">服务点 {{ index + 1 }}</text>
-              <text class="text-red-500 text-sm flex items-center" @click="removeServicePoint(index)">
+            <view class="service-point-header">
+              <view class="flex items-center">
+                <view class="service-point-number">{{ index + 1 }}</view>
+                <text class="service-point-title">{{ point.name || `服务点 ${index + 1}` }}</text>
+              </view>
+              <view class="service-point-delete" @click="removeServicePoint(index)">
                 <text class="uni-icons uniui-trash text-sm mr-1"></text>
-                删除
-              </text>
+                <text>删除</text>
+              </view>
             </view>
-            <view class="gap-y-3">
-              <uni-easyinput class="form-input mb-2" placeholder="服务点名称" v-model="point.name" />
-              <uni-easyinput class="form-input mb-2" placeholder="服务点地址" v-model="point.address" />
-              <uni-easyinput class="form-input mb-2" placeholder="服务类型" v-model="point.serviceType" />
-              <uni-easyinput class="form-input mb-2" placeholder="联系人" v-model="point.contact" />
-              <uni-easyinput class="form-input" placeholder="联系电话" v-model="point.phone" type="number" maxlength="11" />
+            <view class="service-point-body">
+              <view class="form-row">
+                <view class="form-col">
+                  <text class="form-label">名称</text>
+                  <uni-easyinput class="form-input" placeholder="服务点名称" v-model="point.name" />
+                </view>
+                <view class="form-col">
+                  <text class="form-label">对应服务项</text>
+                  <uni-easyinput class="form-input" placeholder="服务项" v-model="point.serviceItem" />
+                </view>
+              </view>
+              <view class="form-group mb-0">
+                <text class="form-label">地址</text>
+                <uni-easyinput class="form-input" placeholder="服务点地址" v-model="point.address" />
+              </view>
             </view>
           </view>
+        </view>
 
-          <button
-            class="add-point-btn w-full py-3 rounded-xl text-sm flex items-center justify-center"
-            @click="addServicePoint"
-          >
-            <text class="uni-icons uniui-plusempty mr-1"></text>
-            添加服务点
-          </button>
+        <view class="add-btn" @click="addServicePoint">
+          <text class="uni-icons uniui-plusempty text-primary mr-1"></text>
+          <text class="text-primary text-sm">添加服务点</text>
         </view>
       </view>
 
       <!-- 备注 -->
-      <view class="card p-4">
-        <view class="detail-section-title mb-4">
-          <view class="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center mr-3">
-            <text class="uni-icons uniui-compose text-gray-500"></text>
+      <view class="card">
+        <view class="section-title">
+          <view class="section-icon section-icon--gray">
+            <text class="uni-icons uniui-compose"></text>
           </view>
-          <text>备注</text>
+          <text class="section-text">备注</text>
         </view>
         <textarea
           v-model="form.remark"
-          class="w-full p-3 bg-surface rounded-xl text-sm text-gray-700"
+          class="form-textarea"
           placeholder="请输入项目备注..."
           :maxlength="200"
         />
       </view>
 
-      <!-- 提交按钮 -->
-      <view class="px-4 pt-2 pb-6">
-        <button
-          class="w-full py-4 btn-primary rounded-2xl text-base font-semibold flex items-center justify-center"
-          @click="submitForm"
-          :disabled="submitting"
-        >
-          <text v-if="submitting" class="uni-icons uniui-spinner-cycle mr-2 animate-spin"></text>
-          <text class="uni-icons uniui-checkmarkempty mr-2" v-else></text>
-          {{ submitting ? '提交中...' : (mode === 'edit' ? '保存修改' : '提交项目') }}
-        </button>
-      </view>
+      <!-- 底部占位 -->
+      <view class="h-20"></view>
+    </view>
+
+    <!-- 提交按钮 -->
+    <view class="submit-bar">
+      <button
+        class="submit-btn"
+        @click="submitForm"
+        :disabled="submitting"
+      >
+        <text v-if="submitting" class="uni-icons uniui-spinner-cycle mr-2 animate-spin"></text>
+        <text class="uni-icons uniui-checkmarkempty mr-2" v-else></text>
+        {{ submitting ? '提交中...' : (mode === 'edit' ? '保存修改' : '提交项目') }}
+      </button>
     </view>
   </view>
 </template>
@@ -264,18 +305,18 @@ import TopBar from '../../components/common/TopBar.vue'
 import { createProject, updateProject, getProjectDetail } from '../../api/project'
 import { getContractList } from '../../api/contract'
 import { getCustomerDetail } from '../../api/customer'
+import { getDictTree, buildDictOptions } from '../../api/dict'
 import {
   getCooperationTypeText,
-  getIndustryText,
-  getPaymentMethodText,
   formatMoney,
-  parseJsonArray
+  parseJsonArray,
+  parseLocationContacts,
+  formatLocationContacts
 } from '../../utils'
 import type {
   Contract,
   CooperationType,
   FacilityType,
-  Industry,
   PaymentMethod,
   ProjectStatus,
   ServicePoint
@@ -286,31 +327,43 @@ const editId = ref<number | string>('')
 const loadingDetail = ref(false)
 const submitting = ref(false)
 
-const cooperationTypes: { value: CooperationType; label: string }[] = [
+// 字典选项
+const cooperationTypeOptions = ref<{ value: CooperationType; label: string }[]>([
   { value: 'annual', label: '包年' },
   { value: 'single', label: '单次' },
   { value: 'quarter', label: '季度' },
   { value: 'monthly', label: '月付' }
-]
-const cooperationLabels = cooperationTypes.map(t => t.label)
-
-const industries: { value: Industry; label: string }[] = [
-  { value: 'residential', label: '住宅' },
-  { value: 'commercial', label: '商业' },
-  { value: 'industrial', label: '工业' },
-  { value: 'municipal', label: '市政' },
-  { value: 'medical', label: '医疗' },
-  { value: 'education', label: '教育' },
-  { value: 'catering', label: '餐饮' }
-]
-const industryLabels = industries.map(t => t.label)
-
-const facilityTypes: { value: FacilityType; label: string }[] = [
+])
+const facilityTypeOptions = ref<{ value: FacilityType; label: string }[]>([
   { value: 'septic_tank', label: '化粪池' },
   { value: 'direct_pump', label: '直抽直排' },
   { value: 'grease_pipe', label: '油污管+化粪池' },
   { value: 'pvc_pipe', label: 'PVC+化粪池' }
-]
+])
+
+async function loadDictOptions() {
+  try {
+    const coopData = await getDictTree('cooperation_type')
+    const coopOpts = buildDictOptions(coopData)
+    if (coopOpts.options.length > 0) {
+      cooperationTypeOptions.value = coopOpts.options as { value: CooperationType; label: string }[]
+    }
+  } catch {
+    // 失败时使用硬编码兜底
+  }
+
+  try {
+    const facilityData = await getDictTree('facility_type')
+    const facilityOpts = buildDictOptions(facilityData)
+    if (facilityOpts.options.length > 0) {
+      facilityTypeOptions.value = facilityOpts.options as { value: FacilityType; label: string }[]
+    }
+  } catch {
+    // 失败时使用硬编码兜底
+  }
+}
+
+const cooperationLabels = computed(() => cooperationTypeOptions.value.map(t => t.label))
 
 const contracts = ref<Contract[]>([])
 const contractLabels = computed(() =>
@@ -328,12 +381,7 @@ interface ProjectForm {
   projectName: string
   contractId: number | string
   cooperationType: CooperationType | ''
-  industry: Industry | ''
   facilityTypes: FacilityType[]
-  occupancyRate: string
-  buildingArea: string
-  contractStartDate: string
-  contractEndDate: string
   projectDescription: string
   servicePoints: ServicePoint[]
   remark: string
@@ -344,17 +392,12 @@ interface ProjectForm {
   contractAmount?: number
   paymentMethod: PaymentMethod | ''
   address: string
-  province: string
-  city: string
-  district: string
-  street: string
-  detailAddress: string
   latitude: string
   longitude: string
   primaryContactName: string
   primaryContactPhone: string
-  backupContactName: string
-  backupContactPhone: string
+  serviceStartDate: string
+  serviceEndDate: string
   status: ProjectStatus
 }
 
@@ -362,12 +405,7 @@ const form = reactive<ProjectForm>({
   projectName: '',
   contractId: '',
   cooperationType: '',
-  industry: '',
   facilityTypes: [],
-  occupancyRate: '',
-  buildingArea: '',
-  contractStartDate: '',
-  contractEndDate: '',
   projectDescription: '',
   servicePoints: [],
   remark: '',
@@ -378,56 +416,37 @@ const form = reactive<ProjectForm>({
   contractAmount: undefined,
   paymentMethod: '',
   address: '',
-  province: '',
-  city: '',
-  district: '',
-  street: '',
-  detailAddress: '',
   latitude: '',
   longitude: '',
   primaryContactName: '',
   primaryContactPhone: '',
-  backupContactName: '',
-  backupContactPhone: '',
+  serviceStartDate: '',
+  serviceEndDate: '',
   status: 'pending'
 })
 
+const locationContacts = ref<{ name: string; phone: string }[]>([])
+
 const errors = reactive<Record<string, string>>({
   contractId: '',
   projectName: '',
   cooperationType: '',
-  industry: '',
   facilityTypes: '',
-  occupancyRate: '',
-  buildingArea: ''
+  address: '',
+  locationContacts: ''
 })
 
 const cooperationIndex = computed(() =>
-  cooperationTypes.findIndex(t => t.value === form.cooperationType)
-)
-const industryIndex = computed(() =>
-  industries.findIndex(t => t.value === form.industry)
+  cooperationTypeOptions.value.findIndex(t => t.value === form.cooperationType)
 )
 
-const derivedAddress = computed(() => {
-  if (form.address) return form.address
-  return [form.province, form.city, form.district, form.street, form.detailAddress].filter(Boolean).join('')
-})
-
-function joinAddress() {
-  return form.address || [form.province, form.city, form.district, form.street, form.detailAddress].filter(Boolean).join('')
-}
-
-function splitServicePeriod(period?: string) {
-  if (!period) return { start: '', end: '' }
-  const parts = period.split(/\s*至\s*/)
-  return { start: parts[0] || '', end: parts[1] || '' }
-}
-
-async function onContractChange(e: any) {
+function onContractChange(e: any) {
   const idx = Number(e.detail.value)
   const contract = contracts.value[idx]
-  if (!contract) return
+  if (!contract) {
+    form.contractId = ''
+    return
+  }
 
   form.contractId = contract.id
   form.customerId = contract.customerId
@@ -436,31 +455,29 @@ async function onContractChange(e: any) {
   form.contractAmount = contract.contractAmount
   form.paymentMethod = contract.paymentMethod
 
+  // 若未手动填写服务有效期,尝试用合同服务期回填
   const period = splitServicePeriod(contract.servicePeriod)
-  if (!form.contractStartDate) form.contractStartDate = period.start
-  if (!form.contractEndDate) form.contractEndDate = period.end
+  if (!form.serviceStartDate) form.serviceStartDate = period.start
+  if (!form.serviceEndDate) form.serviceEndDate = period.end
 
-  errors.contractId = ''
-
-  try {
-    const customer = await getCustomerDetail(contract.customerId)
-    if (customer) {
-      form.customerName = customer.customerName || form.customerName
-      form.customerPhone = customer.phone || ''
-      form.primaryContactName = customer.contactName || ''
-      form.primaryContactPhone = customer.phone || ''
-      form.province = customer.province || ''
-      form.city = customer.city || ''
-      form.district = customer.district || ''
-      form.street = customer.street || ''
-      form.detailAddress = customer.detailAddress || ''
-      form.address = customer.address || joinAddress()
-      form.latitude = customer.latitude || ''
-      form.longitude = customer.longitude || ''
-    }
-  } catch (e) {
-    // 客户详情获取失败不阻断,仅使用合同自带信息
-  }
+  // 尝试获取客户详情以回填联系人/地址
+  getCustomerDetail(contract.customerId)
+    .then((customer) => {
+      if (customer) {
+        form.customerName = customer.customerName || form.customerName
+        form.customerPhone = customer.phone || ''
+        form.primaryContactName = customer.contactName || ''
+        form.primaryContactPhone = customer.phone || ''
+        if (!form.address) {
+          form.address = customer.address || ''
+        }
+        if (!form.latitude) form.latitude = customer.latitude || ''
+        if (!form.longitude) form.longitude = customer.longitude || ''
+      }
+    })
+    .catch(() => {
+      // 客户详情获取失败不阻断
+    })
 
   if (!form.projectName && form.customerName) {
     form.projectName = `${form.customerName}项目`
@@ -468,15 +485,10 @@ async function onContractChange(e: any) {
 }
 
 function onCooperationChange(e: any) {
-  form.cooperationType = cooperationTypes[Number(e.detail.value)].value
+  form.cooperationType = cooperationTypeOptions.value[Number(e.detail.value)].value
   errors.cooperationType = ''
 }
 
-function onIndustryChange(e: any) {
-  form.industry = industries[Number(e.detail.value)].value
-  errors.industry = ''
-}
-
 function toggleFacility(value: FacilityType) {
   const idx = form.facilityTypes.indexOf(value)
   if (idx >= 0) form.facilityTypes.splice(idx, 1)
@@ -484,33 +496,85 @@ function toggleFacility(value: FacilityType) {
   errors.facilityTypes = ''
 }
 
-function onStartDateChange(e: any) {
-  form.contractStartDate = e.detail.value
+function onServiceStartDateChange(e: any) {
+  form.serviceStartDate = e.detail.value
+}
+
+function onServiceEndDateChange(e: any) {
+  form.serviceEndDate = e.detail.value
 }
 
-function onEndDateChange(e: any) {
-  form.contractEndDate = e.detail.value
+function addLocationContact() {
+  locationContacts.value.push({ name: '', phone: '' })
+}
+
+function removeLocationContact(index: number) {
+  locationContacts.value.splice(index, 1)
+}
+
+function chooseLocation() {
+  uni.authorize({
+    scope: 'scope.userLocation',
+    success: () => {
+      uni.chooseLocation({
+        success: (res) => {
+          form.address = res.address || res.name || form.address
+          form.latitude = String(res.latitude ?? '')
+          form.longitude = String(res.longitude ?? '')
+          errors.address = ''
+        },
+        fail: (err) => {
+          console.error('chooseLocation fail', err)
+          uni.showToast({ title: '选择位置失败', icon: 'none' })
+        }
+      })
+    },
+    fail: () => {
+      uni.showModal({
+        title: '需要位置权限',
+        content: '请在设置中允许使用位置信息',
+        success: (res) => {
+          if (res.confirm) {
+            uni.openSetting()
+          }
+        }
+      })
+    }
+  })
 }
 
 function addServicePoint() {
-  form.servicePoints.push({ name: '', address: '', serviceType: '', contact: '', phone: '' })
+  form.servicePoints.push({ name: '', address: '', serviceItem: '' })
 }
 
 function removeServicePoint(index: number) {
   form.servicePoints.splice(index, 1)
 }
 
+function splitServicePeriod(period?: string) {
+  if (!period) return { start: '', end: '' }
+  const parts = period.split(/\s*至\s*/)
+  return { start: parts[0] || '', end: parts[1] || '' }
+}
+
 function validateField(field: string) {
   errors[field] = ''
+  const phonePattern = /^1[3-9]\d{9}$/
   switch (field) {
     case 'projectName':
       if (!form.projectName.trim()) errors.projectName = '请输入项目名称'
       break
-    case 'occupancyRate':
-      if (form.occupancyRate === '' || isNaN(Number(form.occupancyRate))) errors.occupancyRate = '请输入入住率'
+    case 'address':
+      if (!form.address.trim()) errors.address = '请输入项目地址'
       break
-    case 'buildingArea':
-      if (form.buildingArea === '' || isNaN(Number(form.buildingArea))) errors.buildingArea = '请输入建筑面积'
+    case 'locationContacts':
+      if (locationContacts.value.length === 0) {
+        errors.locationContacts = '请至少添加一个位置联系人'
+      } else if (locationContacts.value.some(c => !c.name.trim() || !c.phone.trim())) {
+        errors.locationContacts = '请填写完整的联系人姓名和电话'
+      } else if (locationContacts.value.some(c => !phonePattern.test(c.phone.trim()))) {
+        errors.locationContacts = '请填写正确的手机号'
+      }
       break
   }
   return !errors[field]
@@ -518,56 +582,63 @@ function validateField(field: string) {
 
 function validateForm() {
   let valid = true
-  if (!form.contractId) { errors.contractId = '请选择关联合同'; valid = false } else errors.contractId = ''
+  const phonePattern = /^1[3-9]\d{9}$/
   if (!form.projectName.trim()) { errors.projectName = '请输入项目名称'; valid = false } else errors.projectName = ''
   if (!form.cooperationType) { errors.cooperationType = '请选择合作类型'; valid = false } else errors.cooperationType = ''
-  if (!form.industry) { errors.industry = '请选择所属行业'; valid = false } else errors.industry = ''
   if (form.facilityTypes.length === 0) { errors.facilityTypes = '请选择设施类型'; valid = false } else errors.facilityTypes = ''
-  if (form.occupancyRate === '' || isNaN(Number(form.occupancyRate))) { errors.occupancyRate = '请输入入住率'; valid = false } else errors.occupancyRate = ''
-  if (form.buildingArea === '' || isNaN(Number(form.buildingArea))) { errors.buildingArea = '请输入建筑面积'; valid = false } else errors.buildingArea = ''
+  if (!form.address.trim()) { errors.address = '请输入项目地址'; valid = false } else errors.address = ''
+  if (locationContacts.value.length === 0) {
+    errors.locationContacts = '请至少添加一个位置联系人'
+    valid = false
+  } else if (locationContacts.value.some(c => !c.name.trim() || !c.phone.trim())) {
+    errors.locationContacts = '请填写完整的联系人姓名和电话'
+    valid = false
+  } else if (locationContacts.value.some(c => !phonePattern.test(c.phone.trim()))) {
+    errors.locationContacts = '请填写正确的手机号'
+    valid = false
+  } else {
+    errors.locationContacts = ''
+  }
   return valid
 }
 
 function buildPayload() {
   const points = form.servicePoints
-    .filter(p => p.name || p.address || p.contact || p.phone)
+    .filter(p => p.name || p.address)
     .map(p => ({
       name: p.name || '',
       address: p.address,
-      contact: p.contact,
-      phone: p.phone,
-      serviceType: p.serviceType
+      serviceItem: p.serviceItem
     }))
 
+  // 主联系人从第一个位置联系人推导,回退客户联系人
+  const firstContact = locationContacts.value[0]
+  const primaryContactName = firstContact?.name?.trim() || form.primaryContactName || undefined
+  const primaryContactPhone = firstContact?.phone?.trim() || form.primaryContactPhone || undefined
+
   return {
     projectName: form.projectName.trim(),
-    contractId: form.contractId,
+    contractId: form.contractId || undefined,
     customerId: form.customerId || undefined,
-    customerName: form.customerName || undefined,
+    customerName: form.customerName || form.projectName.trim() || undefined,
     customerPhone: form.customerPhone || undefined,
     contractNo: form.contractNo || undefined,
     cooperationType: form.cooperationType as CooperationType,
-    industry: form.industry as Industry,
+    industry: 'commercial',
     facilityTypes: JSON.stringify(form.facilityTypes),
-    occupancyRate: Number(form.occupancyRate),
-    buildingArea: Number(form.buildingArea),
-    contractStartDate: form.contractStartDate || undefined,
-    contractEndDate: form.contractEndDate || undefined,
+    contractAmount: form.contractAmount,
+    serviceStartDate: form.serviceStartDate || undefined,
+    serviceEndDate: form.serviceEndDate || undefined,
     projectDescription: form.projectDescription || undefined,
     servicePoints: JSON.stringify(points),
-    address: joinAddress() || undefined,
-    province: form.province || undefined,
-    city: form.city || undefined,
-    district: form.district || undefined,
-    street: form.street || undefined,
-    detailAddress: form.detailAddress || undefined,
+    locationContacts: formatLocationContacts(locationContacts.value),
+    address: form.address.trim(),
     latitude: form.latitude || undefined,
     longitude: form.longitude || undefined,
-    primaryContactName: form.primaryContactName || undefined,
-    primaryContactPhone: form.primaryContactPhone || undefined,
-    backupContactName: form.backupContactName || undefined,
-    backupContactPhone: form.backupContactPhone || undefined,
+    primaryContactName,
+    primaryContactPhone,
     paymentMethod: (form.paymentMethod || undefined) as PaymentMethod | undefined,
+    contractStatus: form.contractId ? 'active' : undefined,
     status: form.status,
     remark: form.remark || undefined
   }
@@ -611,19 +682,12 @@ async function loadDetail(id: number | string) {
     form.projectName = p.projectName || ''
     form.contractId = p.contractId || ''
     form.cooperationType = (p.cooperationType || '') as CooperationType | ''
-    form.industry = (p.industry || '') as Industry | ''
     form.facilityTypes = parseJsonArray<FacilityType>(p.facilityTypes)
-    form.occupancyRate = p.occupancyRate !== undefined && p.occupancyRate !== null ? String(p.occupancyRate) : ''
-    form.buildingArea = p.buildingArea !== undefined && p.buildingArea !== null ? String(p.buildingArea) : ''
-    form.contractStartDate = p.contractStartDate || ''
-    form.contractEndDate = p.contractEndDate || ''
     form.projectDescription = p.projectDescription || ''
     form.servicePoints = parseJsonArray<ServicePoint>(p.servicePoints).map(sp => ({
       name: sp.name || '',
       address: sp.address,
-      contact: sp.contact,
-      phone: sp.phone,
-      serviceType: sp.serviceType
+      serviceItem: sp.serviceItem || (sp as any).serviceType
     }))
     form.remark = p.remark || ''
     form.customerId = p.customerId || ''
@@ -633,18 +697,22 @@ async function loadDetail(id: number | string) {
     form.contractAmount = p.contractAmount
     form.paymentMethod = (p.paymentMethod || '') as PaymentMethod | ''
     form.address = p.address || ''
-    form.province = p.province || ''
-    form.city = p.city || ''
-    form.district = p.district || ''
-    form.street = p.street || ''
-    form.detailAddress = p.detailAddress || ''
     form.latitude = p.latitude || ''
     form.longitude = p.longitude || ''
     form.primaryContactName = p.primaryContactName || ''
     form.primaryContactPhone = p.primaryContactPhone || ''
-    form.backupContactName = p.backupContactName || ''
-    form.backupContactPhone = p.backupContactPhone || ''
+    form.serviceStartDate = p.serviceStartDate || ''
+    form.serviceEndDate = p.serviceEndDate || ''
     form.status = p.status || 'pending'
+
+    locationContacts.value = parseLocationContacts(p.locationContacts)
+    // 若后台未存储 locationContacts 但存储了主联系人,则至少回填一个
+    if (locationContacts.value.length === 0 && (form.primaryContactName || form.primaryContactPhone)) {
+      locationContacts.value.push({
+        name: form.primaryContactName || '',
+        phone: form.primaryContactPhone || ''
+      })
+    }
   } catch (e) {
     uni.showToast({ title: '加载项目失败', icon: 'none' })
   } finally {
@@ -653,6 +721,7 @@ async function loadDetail(id: number | string) {
 }
 
 onLoad(async (options) => {
+  await loadDictOptions()
   await loadContracts()
   if (options?.mode === 'edit' && options?.id) {
     mode.value = 'edit'
@@ -663,57 +732,447 @@ onLoad(async (options) => {
 </script>
 
 <style scoped>
+/* 页面基调 */
 .detail-page {
   background-color: #f6fbf9;
+  min-height: 100vh;
+}
+
+.form-body {
+  padding: 12px 16px 80px;
 }
 
+/* 卡片 */
 .card {
+  background: #ffffff;
   border-radius: 20px;
+  padding: 18px 16px;
+  margin-bottom: 12px;
+  box-shadow: 0 4px 20px -4px rgba(54, 143, 111, 0.12);
+  border: 1px solid rgba(164, 216, 152, 0.25);
+}
+
+/* 区块标题 */
+.section-title {
+  display: flex;
+  align-items: center;
+  margin-bottom: 18px;
+}
+.section-icon {
+  width: 34px;
+  height: 34px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10px;
+  font-size: 16px;
+}
+.section-icon--green {
+  background: linear-gradient(135deg, #e6f7ef 0%, #d1f2e3 100%);
+  color: #368f6f;
+}
+.section-icon--blue {
+  background: linear-gradient(135deg, #e6f4fb 0%, #d1ecf7 100%);
+  color: #3a8fb0;
+}
+.section-icon--purple {
+  background: linear-gradient(135deg, #f3e8ff 0%, #ede0fc 100%);
+  color: #8b5cf6;
+}
+.section-icon--yellow {
+  background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
+  color: #f59e0b;
+}
+.section-icon--cyan {
+  background: linear-gradient(135deg, #e6fffa 0%, #d0f7f0 100%);
+  color: #0d9488;
+}
+.section-icon--gray {
+  background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
+  color: #6b7280;
+}
+.section-text {
+  font-size: 16px;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+/* 表单组 */
+.form-group {
+  margin-bottom: 18px;
+}
+.form-group:last-child,
+.form-group.mb-0 {
   margin-bottom: 0;
-  border: 1px solid rgba(164, 216, 152, 0.28);
-  box-shadow: 0 14px 34px -18px rgba(54, 143, 111, 0.35);
 }
 
-.detail-section-title {
-  padding: 0;
-  border-bottom: none;
+.form-label {
+  display: block;
+  font-size: 13px;
+  font-weight: 600;
+  color: #4b5563;
+  margin-bottom: 8px;
 }
-.detail-section-title .w-8 {
-  background-color: rgba(164, 216, 152, 0.2) !important;
+.required {
+  color: #ef4444;
+  margin-left: 2px;
 }
-.detail-section-title .uni-icons {
-  color: #368f6f !important;
+.form-hint {
+  font-weight: 400;
+  color: #9ca3af;
+  margin-left: 6px;
 }
 
-/* 设施类型多选选中态 */
-.bg-primary {
-  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
+/* 输入框 */
+.form-input :deep(.uni-easyinput__content) {
+  min-height: 44px;
+  border-radius: 12px;
+  background-color: #f9fafb;
+  border: 1px solid #e5e7eb;
+  transition: all 0.2s ease;
 }
-.border-primary {
+.form-input :deep(.uni-easyinput__content.focus) {
+  background-color: #ffffff;
   border-color: #368f6f;
+  box-shadow: 0 0 0 3px rgba(54, 143, 111, 0.1);
+}
+.form-input :deep(.uni-easyinput__placeholder-class) {
+  color: #9ca3af;
+  font-size: 14px;
 }
 
-.btn-primary {
+/* 选择器 */
+.form-select {
+  display: flex;
+  align-items: center;
+  min-height: 44px;
+  padding: 0 12px;
+  border-radius: 12px;
+  background-color: #f9fafb;
+  border: 1px solid #e5e7eb;
+  font-size: 14px;
+  color: #1f2937;
+  transition: all 0.2s ease;
+}
+.form-select--empty {
+  color: #9ca3af;
+}
+
+/* 标签组 */
+.chip-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+.chip {
+  padding: 8px 16px;
+  border-radius: 999px;
+  font-size: 13px;
+  font-weight: 500;
+  transition: all 0.15s ease;
+}
+.chip--default {
+  background-color: #f3f4f6;
+  color: #4b5563;
+  border: 1px solid #e5e7eb;
+}
+.chip--active {
   background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
-  box-shadow: 0 10px 22px -10px rgba(54, 143, 111, 0.55);
+  color: #ffffff;
+  border: 1px solid transparent;
+  box-shadow: 0 4px 12px -4px rgba(54, 143, 111, 0.4);
 }
 
-.form-input :deep(.uni-easyinput__content.focus) {
-  border-color: #368f6f !important;
+/* 错误提示 */
+.error-text {
+  display: block;
+  font-size: 12px;
+  color: #ef4444;
+  margin-top: 6px;
 }
 
-.bg-surface {
-  background-color: #ffffff;
+/* 地图按钮 */
+.map-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 52px;
+  height: 44px;
+  border-radius: 12px;
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
+  color: #ffffff;
+  flex-shrink: 0;
+  box-shadow: 0 4px 12px -4px rgba(54, 143, 111, 0.4);
+}
+.map-btn-icon {
+  font-size: 16px;
+  margin-bottom: 1px;
+}
+.map-btn-text {
+  font-size: 10px;
+  line-height: 1;
+}
+
+/* 经纬度提示 */
+.geo-text {
+  display: flex;
+  align-items: center;
+  font-size: 12px;
+  color: #368f6f;
+  margin-top: 6px;
+}
+
+/* 信息面板 */
+.info-panel {
+  background: #f9fafb;
+  border-radius: 14px;
+  padding: 14px;
+  margin-top: 8px;
+}
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 13px;
+  padding: 6px 0;
+}
+.info-row:first-child {
+  padding-top: 0;
+}
+.info-row:last-child {
+  padding-bottom: 0;
+}
+.info-label {
+  color: #6b7280;
+}
+.info-value {
+  color: #1f2937;
+  font-weight: 500;
+}
+
+/* 联系人卡片 */
+.contact-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.contact-card {
+  background: #f9fafb;
+  border-radius: 14px;
+  border: 1px solid #e5e7eb;
+  overflow: hidden;
+}
+.contact-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 12px;
+  background: #f3f4f6;
+  border-bottom: 1px solid #e5e7eb;
+}
+.contact-index {
+  font-size: 13px;
+  font-weight: 600;
+  color: #4b5563;
+}
+.contact-delete {
+  display: flex;
+  align-items: center;
+  font-size: 12px;
+  color: #ef4444;
+  padding: 2px 6px;
+  border-radius: 6px;
+}
+.contact-delete:active {
+  background: rgba(239, 68, 68, 0.1);
+}
+.contact-fields {
+  display: flex;
+  gap: 10px;
+  padding: 12px;
+}
+.contact-field {
+  flex: 1;
+}
+.contact-field-label {
+  display: block;
+  font-size: 12px;
+  color: #6b7280;
+  margin-bottom: 6px;
+}
+.contact-field-input {
+  width: 100%;
+  height: 40px;
+  padding: 0 10px;
+  border-radius: 10px;
   border: 1px solid #e5e7eb;
+  background-color: #ffffff;
+  font-size: 14px;
+  color: #1f2937;
+}
+.contact-field-input:focus {
+  border-color: #368f6f;
 }
 
-/* 添加服务点 */
-.add-point-btn {
-  background-color: rgba(164, 216, 152, 0.10);
-  border: 1px dashed rgba(54, 143, 111, 0.5);
+/* 添加按钮 */
+.add-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 12px 0;
+  border-radius: 12px;
+  border: 1px dashed #a7f3d0;
+  background-color: rgba(164, 216, 152, 0.08);
+  transition: all 0.15s ease;
+}
+.add-btn:active {
+  background-color: rgba(164, 216, 152, 0.18);
+}
+.text-primary {
   color: #368f6f;
 }
-.add-point-btn:active {
-  background-color: rgba(164, 216, 152, 0.22);
+
+/* 日期范围 */
+.date-range {
+  display: flex;
+  align-items: flex-end;
+  gap: 10px;
+}
+.date-field {
+  flex: 1;
+}
+.date-divider {
+  padding-bottom: 12px;
+  font-size: 13px;
+  color: #9ca3af;
+  font-weight: 500;
+}
+
+/* 文本域 */
+.form-textarea {
+  width: 100%;
+  min-height: 90px;
+  padding: 12px;
+  border-radius: 12px;
+  border: 1px solid #e5e7eb;
+  background-color: #f9fafb;
+  font-size: 14px;
+  color: #1f2937;
+  line-height: 1.5;
+}
+.form-textarea:focus {
+  background-color: #ffffff;
+  border-color: #368f6f;
+  box-shadow: 0 0 0 3px rgba(54, 143, 111, 0.1);
+}
+
+/* 服务点卡片 */
+.service-point-list {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  margin-bottom: 14px;
+}
+.service-point-card {
+  background: #f9fafb;
+  border-radius: 16px;
+  border: 1px solid #e5e7eb;
+  overflow: hidden;
+}
+.service-point-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 14px;
+  background: linear-gradient(135deg, #e6fffa 0%, #d0f7f0 100%);
+  border-bottom: 1px solid #e5e7eb;
+}
+.service-point-number {
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  background: #0d9488;
+  color: #ffffff;
+  font-size: 12px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 8px;
+}
+.service-point-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #0f766e;
+}
+.service-point-delete {
+  display: flex;
+  align-items: center;
+  font-size: 12px;
+  color: #ef4444;
+  padding: 2px 6px;
+  border-radius: 6px;
+}
+.service-point-delete:active {
+  background: rgba(239, 68, 68, 0.1);
+}
+.service-point-body {
+  padding: 14px;
+}
+.form-row {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 14px;
+}
+.form-row:last-child {
+  margin-bottom: 0;
+}
+.form-col {
+  flex: 1;
+}
+
+/* 提交栏 */
+.submit-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
+  background: #ffffff;
+  border-top: 1px solid rgba(164, 216, 152, 0.3);
+  box-shadow: 0 -4px 20px -4px rgba(0, 0, 0, 0.06);
+  z-index: 50;
+}
+.submit-btn {
+  width: 100%;
+  height: 48px;
+  border-radius: 14px;
+  font-size: 16px;
+  font-weight: 600;
+  color: #ffffff;
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
+  box-shadow: 0 8px 20px -6px rgba(54, 143, 111, 0.55);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+}
+.submit-btn[disabled] {
+  opacity: 0.7;
+}
+.submit-btn::after {
+  border: none;
+}
+
+/* 动画 */
+.animate-spin {
+  animation: spin 1s linear infinite;
+}
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
 }
 </style>

+ 198 - 78
src/subPackages/pages-common/projectDetail.vue

@@ -13,16 +13,12 @@
     <view v-else class="px-4 py-4 gap-y-4">
       <!-- 头部 -->
       <view class="card p-4">
-        <view class="flex items-start justify-between mb-2">
-          <view class="flex-1 min-w-0 mr-3">
-            <text class="text-lg font-bold text-gray-800">{{ project.projectName }}</text>
-            <text v-if="project.projectCode" class="text-xs text-gray-400 block mt-1">{{ project.projectCode }}</text>
-          </view>
-          <StatusTag :status="project.status" type="project" />
+        <view class="mb-2">
+          <text class="text-lg font-bold text-gray-800">{{ project.projectName }}</text>
+          <text v-if="project.projectCode" class="text-xs text-gray-400 block mt-1">{{ project.projectCode }}</text>
         </view>
         <view class="flex flex-wrap gap-2 mt-3">
           <text class="chip chip-type px-2.5 py-0.5 rounded-full text-xs">{{ getCooperationTypeText(project.cooperationType) }}</text>
-          <text v-if="project.industry" class="chip chip-industry px-2.5 py-0.5 rounded-full text-xs">{{ getIndustryText(project.industry) }}</text>
           <text
             v-for="ft in facilityList"
             :key="ft"
@@ -31,71 +27,46 @@
         </view>
       </view>
 
-      <!-- 合同/费用 -->
+      <!-- 基本信息 -->
       <view class="card p-4">
         <view class="detail-section-title">
-          <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
-          <text>合同 / 费用</text>
+          <text class="uni-icons uniui-info-filled text-primary mr-2"></text>
+          <text>基本信息</text>
         </view>
         <view class="detail-row">
-          <text class="detail-label">合同编号</text>
-          <text class="detail-value">{{ project.contractNo || '-' }}</text>
+          <text class="detail-label">项目名称</text>
+          <text class="detail-value font-medium">{{ project.projectName || '-' }}</text>
         </view>
         <view class="detail-row">
-          <text class="detail-label">合同金额</text>
-          <text class="detail-value">{{ formatMoney(project.contractAmount) }}</text>
+          <text class="detail-label">合作类型</text>
+          <text class="detail-value">
+            <text class="chip chip-type px-2 py-0.5 rounded text-xs">{{ getCooperationTypeText(project.cooperationType) }}</text>
+          </text>
         </view>
         <view class="detail-row">
-          <text class="detail-label">付款方式</text>
-          <text class="detail-value">{{ getPaymentMethodText(project.paymentMethod || '') }}</text>
+          <text class="detail-label">设施类型</text>
+          <text class="detail-value">{{ facilityText }}</text>
         </view>
         <view class="detail-row">
-          <text class="detail-label">合同开始</text>
-          <text class="detail-value">{{ project.contractStartDate || '-' }}</text>
+          <text class="detail-label">项目地址</text>
+          <text class="detail-value text-right flex-1">{{ fullAddress || '-' }}</text>
         </view>
         <view class="detail-row">
-          <text class="detail-label">合同结束</text>
-          <text class="detail-value">{{ project.contractEndDate || '-' }}</text>
+          <text class="detail-label">累计服务</text>
+          <text class="detail-value">
+            <text class="text-primary font-bold text-lg">{{ completedTaskCount }}</text>
+            <text class="text-xs text-gray-500 ml-1">次</text>
+          </text>
         </view>
       </view>
 
-      <!-- 客户 / 联系人 -->
-      <view class="card p-4">
-        <view class="detail-section-title">
-          <text class="uni-icons uniui-person-filled text-primary mr-2"></text>
-          <text>客户 / 联系人</text>
-        </view>
-        <view class="detail-row">
-          <text class="detail-label">客户名称</text>
-          <text class="detail-value">{{ project.customerName || '-' }}</text>
-        </view>
-        <view class="detail-row">
-          <text class="detail-label">主联系人</text>
-          <text class="detail-value">{{ project.primaryContactName || '-' }}</text>
-        </view>
-        <view class="detail-row">
-          <text class="detail-label">联系电话</text>
-          <view class="detail-value flex items-center" @click="callPhone(project.primaryContactPhone || project.customerPhone)">
-            <text class="text-primary">{{ project.primaryContactPhone || project.customerPhone || '-' }}</text>
-          </view>
-        </view>
-        <view v-if="project.backupContactName" class="detail-row">
-          <text class="detail-label">备用联系人</text>
-          <text class="detail-value">{{ project.backupContactName }} {{ project.backupContactPhone || '' }}</text>
-        </view>
-      </view>
-
-      <!-- 地址信息 -->
+      <!-- 位置定位 -->
       <view class="card p-4">
         <view class="detail-section-title">
           <text class="uni-icons uniui-location-filled text-primary mr-2"></text>
-          <text>地址信息</text>
-        </view>
-        <view class="detail-row">
-          <text class="detail-label">完整地址</text>
-          <text class="detail-value text-right flex-1">{{ fullAddress || '-' }}</text>
+          <text>位置定位</text>
         </view>
-        <view v-if="hasLocation" class="mt-3">
+        <view v-if="hasLocation" class="mt-1">
           <MapView
             :latitude="projectLatitude"
             :longitude="projectLongitude"
@@ -104,40 +75,100 @@
             :height="180"
           />
         </view>
+        <view v-else class="py-6 text-center">
+          <text class="text-sm text-gray-400">未设置地图定位</text>
+        </view>
       </view>
 
-      <!-- 项目介绍 -->
+      <!-- 项目情况介绍 -->
       <view v-if="project.projectDescription" class="card p-4">
         <view class="detail-section-title">
           <text class="uni-icons uniui-info-filled text-primary mr-2"></text>
-          <text>项目介绍</text>
+          <text>项目情况介绍</text>
+        </view>
+        <text class="text-sm text-gray-600 leading-relaxed">{{ project.projectDescription }}</text>
+      </view>
+
+      <!-- 位置联系人 -->
+      <view v-if="locationContacts.length > 0" class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-person-filled text-primary mr-2"></text>
+          <text>位置联系人</text>
+        </view>
+        <view v-for="(contact, index) in locationContacts" :key="index" class="contact-item">
+          <view class="contact-avatar">
+            <text class="uni-icons uniui-person text-primary"></text>
+          </view>
+          <view class="flex-1 min-w-0 mr-3">
+            <text class="text-sm font-medium text-gray-800 block">{{ contact.name || `联系人 ${index + 1}` }}</text>
+            <text class="text-xs text-gray-400">{{ maskPhone(contact.phone) }}</text>
+          </view>
+          <view class="call-btn" @click="callPhone(contact.phone)">
+            <text class="uni-icons uniui-phone text-white text-sm"></text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 费用信息 -->
+      <view class="card p-4">
+        <view class="detail-section-title">
+          <text class="uni-icons uniui-wallet-filled text-primary mr-2"></text>
+          <text>费用信息</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">合同总金额</text>
+          <text class="detail-value text-xl font-bold text-orange-500">{{ formatMoney(project.contractAmount) }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">付款方式</text>
+          <text class="detail-value">{{ getPaymentMethodText(project.paymentMethod || '') }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">合同状态</text>
+          <text class="detail-value">
+            <text
+              class="px-2 py-0.5 rounded text-xs font-medium"
+              :style="{ backgroundColor: contractStatusBg, color: contractStatusColor }"
+            >{{ contractStatusText }}</text>
+          </text>
         </view>
-        <text class="text-sm text-gray-600">{{ project.projectDescription }}</text>
       </view>
 
-      <!-- 服务点 -->
+      <!-- 服务点与对应服务项 -->
       <view class="card p-4">
         <view class="detail-section-title">
           <text class="uni-icons uniui-location-filled text-primary mr-2"></text>
-          <text>服务点与服务项</text>
+          <text>服务点与对应服务项</text>
         </view>
         <view v-if="servicePointList.length > 0">
           <view
             v-for="(point, index) in servicePointList"
             :key="index"
-            class="py-3"
+            class="service-point-item"
             :class="{ 'border-t border-gray-100': index > 0 }"
           >
-            <view class="flex items-center mb-1">
-              <text class="uni-icons uniui-location-filled text-primary mr-2"></text>
-              <text class="text-sm font-medium text-gray-800">{{ point.name || `服务点 ${index + 1}` }}</text>
+            <view class="flex items-center justify-between mb-2">
+              <view class="flex items-center min-w-0">
+                <text class="text-sm font-medium text-gray-800 truncate">服务点{{ index + 1 }}:{{ point.name || '未命名' }}</text>
+              </view>
+              <text
+                class="px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ml-2"
+                :style="{ backgroundColor: index === 0 ? 'rgba(164, 216, 152, 0.22)' : '#f3f4f6', color: index === 0 ? '#368f6f' : '#6b7280' }"
+              >{{ index === 0 ? '主服务点' : '正常' }}</text>
             </view>
-            <view class="pl-6">
-              <text v-if="point.address" class="text-xs text-gray-500 block mb-1">{{ point.address }}</text>
-              <view class="flex items-center flex-wrap gap-2">
-                <text v-if="point.serviceType" class="px-2 py-1 bg-primary-light text-primary rounded text-xs">{{ point.serviceType }}</text>
-                <text v-if="point.contact" class="text-xs text-gray-500">联系人:{{ point.contact }}</text>
-                <text v-if="point.phone" class="text-xs text-gray-500">{{ point.phone }}</text>
+            <view class="space-y-1">
+              <view class="flex items-center">
+                <text class="uni-icons uniui-location-filled text-gray-400 text-xs mr-1.5"></text>
+                <text class="text-xs text-gray-500">位置:{{ point.address || '-' }}</text>
+              </view>
+              <view class="flex items-center">
+                <text class="uni-icons uniui-settings-filled text-gray-400 text-xs mr-1.5"></text>
+                <text class="text-xs text-gray-500">服务项:</text>
+                <text class="px-2 py-0.5 bg-primary-light text-primary rounded text-xs">{{ point.serviceItem || point.serviceType || '-' }}</text>
+              </view>
+              <view class="flex items-center">
+                <text class="uni-icons uniui-clock text-gray-400 text-xs mr-1.5"></text>
+                <text class="text-xs text-gray-400">最近服务:{{ getLastServiceTime(point) }}</text>
               </view>
             </view>
           </view>
@@ -163,7 +194,7 @@
           @click="goToEdit"
         >
           <text class="uni-icons uniui-compose mr-2"></text>
-          编辑
+          编辑项目
         </button>
         <button
           class="flex-1 py-4 btn-primary rounded-2xl text-base font-semibold flex items-center justify-center"
@@ -180,27 +211,43 @@
 <script setup lang="ts">
 import { ref, computed } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
-import type { Project, ServicePoint } from '../../types'
+import type { Project, ServicePoint, Task } from '../../types'
 import { getProjectDetail } from '../../api/project'
+import { getTasksByProjectId } from '../../api/task'
 import {
   formatMoney,
   parseJsonArray,
+  parseLocationContacts,
   getCooperationTypeText,
-  getIndustryText,
   getFacilityTypeText,
-  getPaymentMethodText
+  getPaymentMethodText,
+  getContractStatusText,
+  getContractStatusColor,
+  getContractStatusBgColor
 } from '../../utils'
 import TopBar from '../../components/common/TopBar.vue'
-import StatusTag from '../../components/common/StatusTag.vue'
 import EmptyState from '../../components/common/EmptyState.vue'
 import MapView from '../../components/common/MapView.vue'
 
 const project = ref<Project | null>(null)
+const tasks = ref<Task[]>([])
 const loading = ref(true)
 const projectId = ref<number | string>('')
 
 const facilityList = computed(() => parseJsonArray<string>(project.value?.facilityTypes))
+const facilityText = computed(() => {
+  const list = facilityList.value
+  if (list.length === 0) return '-'
+  return list.map(t => getFacilityTypeText(t)).join('、')
+})
 const servicePointList = computed(() => parseJsonArray<ServicePoint>(project.value?.servicePoints))
+const locationContacts = computed(() => parseLocationContacts(project.value?.locationContacts))
+
+const completedTaskCount = computed(() => tasks.value.filter(t => t.status === 'completed').length)
+
+const contractStatusText = computed(() => getContractStatusText(project.value?.contractStatus || ''))
+const contractStatusColor = computed(() => getContractStatusColor(project.value?.contractStatus || ''))
+const contractStatusBg = computed(() => getContractStatusBgColor(project.value?.contractStatus || ''))
 
 const fullAddress = computed(() => {
   if (!project.value) return ''
@@ -213,11 +260,38 @@ const hasLocation = computed(() => !!(project.value?.latitude && project.value?.
 const projectLatitude = computed(() => Number(project.value?.latitude) || 39.9)
 const projectLongitude = computed(() => Number(project.value?.longitude) || 116.4)
 
+function maskPhone(phone?: string) {
+  if (!phone) return ''
+  if (phone.length === 11) {
+    return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
+  }
+  return phone
+}
+
 function callPhone(phone?: string) {
   if (!phone) return
   uni.makePhoneCall({ phoneNumber: phone })
 }
 
+function getPointTasks(point: ServicePoint) {
+  return tasks.value.filter(t =>
+    t.status === 'completed' &&
+    (t.address?.includes(point.name || '') || t.faultLocation?.includes(point.name || ''))
+  )
+}
+
+function getLastServiceTime(point: ServicePoint) {
+  const pointTasks = getPointTasks(point)
+  if (pointTasks.length === 0) return '暂无服务记录'
+  const sorted = pointTasks.slice().sort((a, b) => {
+    const ta = new Date(a.completionTime || a.updateTime || 0).getTime()
+    const tb = new Date(b.completionTime || b.updateTime || 0).getTime()
+    return tb - ta
+  })
+  const time = sorted[0]?.completionTime || sorted[0]?.updateTime
+  return time || '暂无'
+}
+
 function goToEdit() {
   uni.navigateTo({ url: `/subPackages/pages-common/addProject?mode=edit&id=${projectId.value}` })
 }
@@ -229,9 +303,15 @@ function goToPublishTask() {
 async function loadDetail(id: number | string) {
   loading.value = true
   try {
-    project.value = await getProjectDetail(id)
+    const [p, taskList] = await Promise.all([
+      getProjectDetail(id),
+      getTasksByProjectId(id).catch(() => [])
+    ])
+    project.value = p
+    tasks.value = Array.isArray(taskList) ? taskList : []
   } catch (e) {
     project.value = null
+    tasks.value = []
   } finally {
     loading.value = false
   }
@@ -277,10 +357,6 @@ onLoad((options) => {
   background-color: rgba(164, 216, 152, 0.22);
   color: #368f6f;
 }
-.chip-industry {
-  background-color: rgba(90, 184, 208, 0.16);
-  color: #3a8fb0;
-}
 .chip-facility {
   background-color: #f3f4f6;
   color: #6b7280;
@@ -291,6 +367,50 @@ onLoad((options) => {
   background-color: rgba(164, 216, 152, 0.22);
 }
 
+/* 联系人 */
+.contact-item {
+  display: flex;
+  align-items: center;
+  padding: 10px 0;
+}
+.contact-item + .contact-item {
+  border-top: 1px solid rgba(164, 216, 152, 0.20);
+}
+.contact-avatar {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background-color: rgba(164, 216, 152, 0.22);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 12px;
+  flex-shrink: 0;
+}
+.call-btn {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  box-shadow: 0 6px 14px -6px rgba(54, 143, 111, 0.55);
+}
+.call-btn:active {
+  opacity: 0.92;
+  transform: scale(0.94);
+}
+
+/* 服务点 */
+.service-point-item {
+  padding: 14px 0;
+}
+.service-point-item:first-child {
+  padding-top: 0;
+}
+
 /* 操作按钮 */
 .btn-ghost {
   background-color: #ffffff;

+ 55 - 29
src/subPackages/pages-common/projectList.vue

@@ -60,25 +60,32 @@
           @click="onSwipeAction(project.projectId, $event)"
         >
           <view class="data-card" @click="goToDetail(project.projectId)">
-            <!-- 头部:项目名/客户 + 合作类型 -->
-            <view class="flex items-start justify-between">
-              <view class="min-w-0">
-                <text class="card-title block truncate">{{ project.projectName }}</text>
-                <view class="mt-1 flex items-center">
-                  <text class="uni-icons uniui-person-filled info-icon text-xs mr-1 flex-shrink-0"></text>
-                  <text class="info-text text-xs truncate">{{ project.customerName || '暂无客户' }}</text>
-                </view>
-              </view>
-              <view class="type-pill flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs ml-2">
+            <!-- 头部:项目名 + 合作类型 -->
+            <view class="flex items-start justify-between mb-3">
+              <text class="card-title flex-1 min-w-0 truncate mr-3">{{ project.projectName }}</text>
+              <view class="type-pill px-2.5 py-0.5 rounded-full text-xs flex-shrink-0">
                 {{ getCooperationTypeText(project.cooperationType) }}
               </view>
             </view>
 
-            <!-- 底部地址行 -->
-            <view class="card-foot mt-3 pt-2.5 flex items-center">
-              <text class="uni-icons uniui-location-filled foot-icon text-xs mr-1.5 flex-shrink-0"></text>
-              <text class="foot-text text-xs truncate flex-1">{{ addressText(project) }}</text>
-              <text class="uni-icons uniui-arrowright card-arrow ml-2 flex-shrink-0"></text>
+            <!-- 信息行 -->
+            <view class="space-y-2">
+              <view class="info-row">
+                <text class="uni-icons uniui-settings-filled info-icon text-xs mr-2 flex-shrink-0"></text>
+                <text class="info-text text-xs truncate">{{ facilityText(project) }}</text>
+              </view>
+              <view class="info-row">
+                <text class="uni-icons uniui-location-filled info-icon text-xs mr-2 flex-shrink-0"></text>
+                <text class="info-text text-xs truncate">{{ addressText(project) }}</text>
+              </view>
+              <view class="info-row">
+                <text class="uni-icons uniui-map-pin-ellipse info-icon text-xs mr-2 flex-shrink-0"></text>
+                <text class="info-text text-xs">服务点数:<text class="text-primary font-medium">{{ servicePointCount(project) }}个</text></text>
+              </view>
+              <view class="info-row">
+                <text class="uni-icons uniui-person-filled info-icon text-xs mr-2 flex-shrink-0"></text>
+                <text class="info-text text-xs truncate">{{ contactText(project) }}</text>
+              </view>
             </view>
           </view>
         </uni-swipe-action>
@@ -104,7 +111,7 @@ import { ref, computed, onMounted } from 'vue'
 import { onPullDownRefresh } from '@dcloudio/uni-app'
 import { useProjectStore } from '../../stores/project'
 import type { CooperationType, Project } from '../../types'
-import { getCooperationTypeText } from '../../utils'
+import { getCooperationTypeText, getFacilityTypeText, parseJsonArray } from '../../utils'
 import EmptyState from '../../components/common/EmptyState.vue'
 import TopBar from '../../components/common/TopBar.vue'
 import UniSwipeAction from '../../components/uni-swipe-action/uni-swipe-action.vue'
@@ -153,6 +160,33 @@ function addressText(project: Project) {
   )
 }
 
+function facilityText(project: Project) {
+  const list = parseJsonArray<string>(project.facilityTypes)
+  if (list.length === 0) return '暂无设施类型'
+  return list.map(t => getFacilityTypeText(t)).join('、')
+}
+
+function servicePointCount(project: Project) {
+  return parseJsonArray(project.servicePoints).length
+}
+
+function maskPhone(phone?: string) {
+  if (!phone) return ''
+  if (phone.length === 11) {
+    return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
+  }
+  return phone
+}
+
+function contactText(project: Project) {
+  const name = project.primaryContactName
+  const phone = maskPhone(project.primaryContactPhone)
+  if (name && phone) return `${name} · ${phone}`
+  if (name) return name
+  if (phone) return phone
+  return '暂无联系人'
+}
+
 function onFilterChange(index: number) {
   currentFilterIndex.value = index
 }
@@ -284,26 +318,18 @@ onPullDownRefresh(() => {
 }
 
 .info-icon {
-  color: #5ab8d0;
+  color: #9ca3af;
 }
 .info-text {
   color: #6b7280;
 }
-
-/* 底部地址行 */
-.card-foot {
-  border-top: 1px dashed rgba(164, 216, 152, 0.45);
+.info-row {
+  display: flex;
+  align-items: center;
 }
-.foot-icon {
+.text-primary {
   color: #368f6f;
 }
-.foot-text {
-  color: #9ca3af;
-}
-.card-arrow {
-  color: #c4d9cc;
-  font-size: 14px;
-}
 
 .loading-text {
   color: #6b7280;

+ 167 - 592
src/subPackages/pages-common/publishTask.vue

@@ -15,25 +15,6 @@
           <text class="hint-text">请从项目库选择或新增项目</text>
         </view>
 
-        <view class="form-group">
-          <text class="form-label">
-            <text class="text-red-500">*</text>任务名称
-          </text>
-          <uni-easyinput class="form-input" v-model="taskName" placeholder="如:3号楼化粪池清掏" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">
-            <text class="text-red-500">*</text>任务类型
-          </text>
-          <picker mode="selector" :range="taskTypeLabels" :value="taskTypeIndex" @change="onTaskTypeChange">
-            <view class="form-select flex justify-between items-center">
-              <text :class="taskType ? 'text-gray-800' : 'text-gray-400'">{{ taskTypeLabel || '请选择任务类型' }}</text>
-              <text class="uni-icons uniui-arrowdown text-gray-400"></text>
-            </view>
-          </picker>
-        </view>
-
         <view class="form-group">
           <text class="form-label">
             <text class="text-red-500">*</text>选择项目
@@ -54,27 +35,63 @@
           <view class="gap-y-2">
             <view class="flex justify-between text-sm">
               <text class="text-gray-500">合作类型:</text>
-              <text class="text-gray-700">{{ getCooperationTypeText(selectedProject.cooperationType) }}</text>
+              <text class="text-gray-700">{{ form.cooperationType || '-' }}</text>
             </view>
             <view class="flex justify-between text-sm">
               <text class="text-gray-500">设施类型:</text>
-              <text class="text-gray-700 flex-1 text-right">{{ selectedFacilityText }}</text>
-            </view>
-            <view class="flex justify-between text-sm">
-              <text class="text-gray-500">项目地址:</text>
-              <text class="text-gray-700 flex-1 text-right">{{ selectedAddressText }}</text>
+              <text class="text-gray-700 flex-1 text-right">{{ form.facilityType || '-' }}</text>
             </view>
           </view>
         </view>
 
+        <view class="form-group">
+          <text class="form-label">选择服务点</text>
+          <picker mode="selector" :range="servicePointNames" :value="servicePointIndex" @change="onServicePointChange" :disabled="servicePointNames.length === 0">
+            <view class="form-select flex justify-between items-center">
+              <text :class="form.servicePointName ? 'text-gray-800' : 'text-gray-400'">{{ selectedServicePointText }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">
+            <text class="text-red-500">*</text>项目地址
+          </text>
+          <uni-easyinput class="form-input" v-model="form.address" placeholder="选择项目或服务点后自动带出" />
+        </view>
+
         <view class="form-group">
           <text class="form-label">主联系人姓名</text>
-          <uni-easyinput class="form-input" v-model="contactName" placeholder="自动带出,可修改" />
+          <uni-easyinput class="form-input" v-model="form.contactName" placeholder="自动带出,可修改" />
         </view>
 
         <view class="form-group mb-0">
           <text class="form-label">主联系人电话</text>
-          <uni-easyinput class="form-input" v-model="contactPhone" placeholder="自动带出,可修改" />
+          <uni-easyinput class="form-input" v-model="form.contactPhone" placeholder="自动带出,可修改" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">备用联系人</text>
+          <view v-if="form.contacts.length > 0" class="gap-y-2 mb-2">
+            <view
+              v-for="(contact, index) in form.contacts"
+              :key="index"
+              class="contact-row"
+            >
+              <view class="flex-1">
+                <input v-model="contact.name" class="contact-input" placeholder="姓名" />
+              </view>
+              <view class="flex-1 mx-2">
+                <input v-model="contact.phone" class="contact-input" placeholder="电话" />
+              </view>
+              <text class="uni-icons uniui-close text-gray-400 ml-2" @click="removeContact(index)"></text>
+            </view>
+          </view>
+          <view class="add-contact-btn" @click="addContact">
+            <text class="uni-icons uniui-plus text-green-500 mr-1"></text>
+            <text class="text-green-500 text-sm">添加备用联系人</text>
+          </view>
         </view>
       </view>
 
@@ -85,11 +102,31 @@
           <text class="section-title">服务信息</text>
         </view>
 
+        <view class="form-group">
+          <text class="form-label">
+            <text class="text-red-500">*</text>服务时间
+          </text>
+          <view class="flex gap-2">
+            <picker mode="date" :value="serviceTimeDate" @change="onServiceTimeDateChange" class="flex-1">
+              <view class="form-select flex justify-between items-center">
+                <text :class="serviceTimeDate ? 'text-gray-800' : 'text-gray-400'">{{ serviceTimeDate || '选择日期' }}</text>
+                <text class="uni-icons uniui-calendar text-gray-400"></text>
+              </view>
+            </picker>
+            <picker mode="time" :value="serviceTimeTime" @change="onServiceTimeTimeChange" style="flex: 0.6;">
+              <view class="form-select flex justify-between items-center">
+                <text :class="serviceTimeTime ? 'text-gray-800' : 'text-gray-400'">{{ serviceTimeTime || '选择时间' }}</text>
+                <text class="uni-icons uniui-clock text-gray-400"></text>
+              </view>
+            </picker>
+          </view>
+        </view>
+
         <view class="form-group">
           <text class="form-label">
             <text class="text-red-500">*</text>故障位置
           </text>
-          <uni-easyinput class="form-input" v-model="faultLocation" placeholder="如:3号楼东侧化粪池" />
+          <uni-easyinput class="form-input" v-model="form.faultLocation" placeholder="如:3号楼东侧化粪池" />
         </view>
 
         <view class="form-group">
@@ -97,7 +134,7 @@
           <view class="flex items-center gap-2">
             <uni-easyinput
               class="form-input flex-1"
-              v-model="geoLocation"
+              v-model="form.geoLocation"
               placeholder="请输入地理位置或点击地图选点"
             />
             <view class="map-btn" @click="chooseLocation">
@@ -105,286 +142,119 @@
               <text class="map-btn-text">地图</text>
             </view>
           </view>
-          <text v-if="latitude && longitude" class="text-xs text-gray-400 mt-1">
-            {{ latitude }}, {{ longitude }}
+          <text v-if="form.latitude && form.longitude" class="text-xs text-gray-400 mt-1">
+            {{ form.latitude }}, {{ form.longitude }}
           </text>
         </view>
 
         <view class="form-group">
           <text class="form-label">
-            <text class="text-red-500">*</text>故障类型
+            <text class="text-red-500">*</text>故障描述
           </text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="type in faultTypes"
-              :key="type"
-              class="fault-chip"
-              :class="{ 'fault-chip-on': selectedFaultTypes.includes(type) }"
-              @click="toggleFaultType(type)"
-            >
-              {{ type }}
-            </view>
-          </view>
+          <textarea class="form-textarea" v-model="form.faultDescription" placeholder="请详细描述故障情况" />
         </view>
 
         <view class="form-group">
-          <text class="form-label">客户原话</text>
-          <textarea class="form-textarea" v-model="customerWords" placeholder="记录客户的原始描述" />
+          <text class="form-label">故障图片/视频</text>
+          <PhotoUploader v-model="form.faultPhotos" :max-count="9" />
         </view>
 
         <view class="form-group">
           <text class="form-label">
-            <text class="text-red-500">*</text>故障描述
+            <text class="text-red-500">*</text>施工类型
           </text>
-          <textarea class="form-textarea" v-model="faultDescription" placeholder="详细描述故障情况" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">首次出现时间</text>
-          <view class="flex gap-2">
-            <picker mode="date" :value="firstOccurrenceDate" @change="onFirstOccurrenceDateChange" class="flex-1">
-              <view class="form-select flex justify-between items-center">
-                <text :class="firstOccurrenceDate ? 'text-gray-800' : 'text-gray-400'">{{ firstOccurrenceDate || '选择日期' }}</text>
-                <text class="uni-icons uniui-calendar text-gray-400"></text>
-              </view>
-            </picker>
-            <picker mode="time" :value="firstOccurrenceTime" @change="onFirstOccurrenceTimeChange" style="flex: 0.6;">
-              <view class="form-select flex justify-between items-center">
-                <text :class="firstOccurrenceTime ? 'text-gray-800' : 'text-gray-400'">{{ firstOccurrenceTime || '选择时间' }}</text>
-                <text class="uni-icons uniui-clock text-gray-400"></text>
-              </view>
-            </picker>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">近期反复情况</text>
-          <picker mode="selector" :range="recurrenceOptions" :value="recurrenceIndex" @change="onRecurrenceChange">
+          <picker mode="selector" :range="constructionTypeLabels" :value="constructionTypeIndex" @change="onConstructionTypeChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="recurrenceStatus ? 'text-gray-800' : 'text-gray-400'">{{ recurrenceStatus || '请选择' }}</text>
+              <text :class="form.constructionType ? 'text-gray-800' : 'text-gray-400'">{{ form.constructionType || '请选择施工类型' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
 
         <view class="form-group">
-          <text class="form-label">影响范围</text>
-          <picker mode="selector" :range="impactScopeOptions" :value="impactScopeIndex" @change="onImpactScopeChange">
-            <view class="form-select flex justify-between items-center">
-              <text :class="impactScope ? 'text-gray-800' : 'text-gray-400'">{{ impactScope || '请选择影响范围' }}</text>
-              <text class="uni-icons uniui-arrowdown text-gray-400"></text>
-            </view>
-          </picker>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">故障图片/视频</text>
-          <PhotoUploader v-model="faultPhotos" :max-count="9" />
-        </view>
-
-        <view class="form-group mb-0">
-          <text class="form-label">施工类型</text>
-          <picker mode="selector" :range="constructionTypes" :value="constructionTypeIndex" @change="onConstructionTypeChange">
+          <text class="form-label">
+            <text class="text-red-500">*</text>施工设施
+          </text>
+          <picker mode="selector" :range="constructionFacilityLabels" :value="constructionFacilityIndex" @change="onConstructionFacilityChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="constructionType ? 'text-gray-800' : 'text-gray-400'">{{ constructionType || '请选择施工类型' }}</text>
+              <text :class="form.constructionFacility ? 'text-gray-800' : 'text-gray-400'">{{ form.constructionFacility || '请选择施工设施' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
-      </view>
-
-      <!-- 服务与结算数据 -->
-      <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title">服务与结算数据</text>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">期望到达时间</text>
-          <view class="flex gap-2">
-            <picker mode="date" :value="expectedArrivalDate" @change="onExpectedArrivalDateChange" class="flex-1">
-              <view class="form-select flex justify-between items-center">
-                <text :class="expectedArrivalDate ? 'text-gray-800' : 'text-gray-400'">{{ expectedArrivalDate || '选择日期' }}</text>
-                <text class="uni-icons uniui-calendar text-gray-400"></text>
-              </view>
-            </picker>
-            <picker mode="time" :value="expectedArrivalTime" @change="onExpectedArrivalTimeChange" style="flex: 0.6;">
-              <view class="form-select flex justify-between items-center">
-                <text :class="expectedArrivalTime ? 'text-gray-800' : 'text-gray-400'">{{ expectedArrivalTime || '选择时间' }}</text>
-                <text class="uni-icons uniui-clock text-gray-400"></text>
-              </view>
-            </picker>
-          </view>
-        </view>
 
         <view class="form-group">
-          <text class="form-label">最晚处理时限</text>
-          <view class="flex gap-2">
-            <picker mode="date" :value="latestDeadlineDate" @change="onLatestDeadlineDateChange" class="flex-1">
-              <view class="form-select flex justify-between items-center">
-                <text :class="latestDeadlineDate ? 'text-gray-800' : 'text-gray-400'">{{ latestDeadlineDate || '选择日期' }}</text>
-                <text class="uni-icons uniui-calendar text-gray-400"></text>
-              </view>
-            </picker>
-            <picker mode="time" :value="latestDeadlineTime" @change="onLatestDeadlineTimeChange" style="flex: 0.6;">
-              <view class="form-select flex justify-between items-center">
-                <text :class="latestDeadlineTime ? 'text-gray-800' : 'text-gray-400'">{{ latestDeadlineTime || '选择时间' }}</text>
-                <text class="uni-icons uniui-clock text-gray-400"></text>
-              </view>
-            </picker>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">验收标准</text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="item in acceptanceStandardOptions"
-              :key="item"
-              class="radio-chip"
-              :class="{ 'radio-chip-on': acceptanceStandard === item }"
-              @click="acceptanceStandard = item"
-            >
-              {{ item }}
-            </view>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">结算方式</text>
-          <picker mode="selector" :range="settlementMethodOptions" :value="settlementMethodIndex" @change="onSettlementMethodChange">
+          <text class="form-label">
+            <text class="text-red-500">*</text>服务方式
+          </text>
+          <picker mode="selector" :range="serviceMethodLabels" :value="serviceMethodIndex" @change="onServiceMethodChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="settlementMethod ? 'text-gray-800' : 'text-gray-400'">{{ settlementMethod || '请选择结算方式' }}</text>
+              <text :class="form.serviceMethod ? 'text-gray-800' : 'text-gray-400'">{{ form.serviceMethod || '请选择服务方式' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
 
         <view class="form-group">
-          <text class="form-label">堵点个数</text>
-          <uni-easyinput class="form-input" v-model="blockageCount" type="digit" placeholder="请输入" />
-        </view>
-
-        <view class="form-group mb-0">
-          <text class="form-label">清淤方量(m³)</text>
-          <uni-easyinput class="form-input" v-model="dredgingVolume" type="digit" placeholder="请输入" />
-        </view>
-      </view>
-
-      <!-- 工单生成数据 -->
-      <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title">工单生成数据</text>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">施工指令</text>
-          <textarea class="form-textarea" v-model="constructionInstruction" placeholder="根据故障类型自动生成" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">风险提示</text>
-          <textarea class="form-textarea" v-model="riskTip" placeholder="根据故障类型自动生成" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">派单结果判定</text>
+          <text class="form-label">
+            <text class="text-red-500">*</text>建议出车
+          </text>
           <view class="flex flex-wrap gap-2">
             <view
-              v-for="item in dispatchResultOptions"
-              :key="item"
-              class="radio-chip"
-              :class="{ 'radio-chip-on': dispatchResult === item }"
-              @click="dispatchResult = item"
+              v-for="item in suggestedVehicleOptions"
+              :key="item.value"
+              class="fault-chip"
+              :class="{ 'fault-chip-on': form.suggestedVehicles.includes(item.value) }"
+              @click="toggleVehicle(item.value)"
             >
-              {{ item }}
+              {{ item.label }}
             </view>
           </view>
         </view>
 
         <view class="form-group">
-          <text class="form-label">施工设施</text>
-          <picker mode="selector" :range="constructionFacilityOptions" :value="constructionFacilityIndex" @change="onConstructionFacilityChange">
+          <text class="form-label">取水距离</text>
+          <picker mode="selector" :range="waterDistanceLabels" :value="waterDistanceIndex" @change="onWaterDistanceChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="constructionFacility ? 'text-gray-800' : 'text-gray-400'">{{ constructionFacility || '请选择施工设施' }}</text>
+              <text :class="form.waterDistance ? 'text-gray-800' : 'text-gray-400'">{{ form.waterDistance || '请选择取水距离' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
 
         <view class="form-group">
-          <text class="form-label">服务方式</text>
-          <uni-easyinput class="form-input" v-model="serviceMethod" placeholder="如:月计划" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">建议出车</text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="v in vehicleOptions"
-              :key="v"
-              class="fault-chip"
-              :class="{ 'fault-chip-on': suggestedVehicles.includes(v) }"
-              @click="toggleVehicle(v)"
-            >
-              {{ v }}
-            </view>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">取水距离</text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="item in waterOptions"
-              :key="item"
-              class="radio-chip"
-              :class="{ 'radio-chip-on': waterDistance === item }"
-              @click="waterDistance = item"
-            >
-              {{ item }}
-            </view>
-          </view>
-        </view>
-
-        <view class="form-group mb-0">
           <text class="form-label">化粪池排口是否正常</text>
           <view class="flex flex-wrap gap-2">
             <view
               v-for="item in yesNoOptions"
               :key="item.value"
               class="radio-chip"
-              :class="{ 'radio-chip-on': outletNormal === item.value }"
-              @click="outletNormal = item.value"
+              :class="{ 'radio-chip-on': form.outletNormal === item.value }"
+              @click="setOutletNormal(item.value)"
             >
               {{ item.label }}
             </view>
           </view>
         </view>
-      </view>
 
-      <!-- 施工助力信息 -->
-      <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title">施工助力信息</text>
+        <view class="form-group">
+          <text class="form-label">施工助力信息</text>
+          <textarea class="form-textarea" v-model="form.constructionHelp" placeholder="如地库限高、需搭脚手架、登高作业等" />
         </view>
 
         <view class="form-group">
           <text class="form-label">预计作业量</text>
-          <uni-easyinput class="form-input" v-model="estimatedWorkload" placeholder="如:2车" />
+          <uni-easyinput class="form-input" v-model="form.estimatedQuantity" placeholder="如:2个堵点" />
         </view>
 
         <view class="form-group">
           <text class="form-label">预计工时</text>
-          <uni-easyinput class="form-input" v-model="estimatedDuration" placeholder="如:4小时" />
+          <uni-easyinput class="form-input" v-model="form.estimatedHours" placeholder="如:2小时" />
         </view>
 
         <view class="form-group">
           <text class="form-label">验收要求</text>
-          <uni-easyinput class="form-input" v-model="acceptanceRequirement" placeholder="验收的具体要求" />
+          <uni-easyinput class="form-input" v-model="form.acceptanceReq" placeholder="如:正常走水" />
         </view>
 
         <view class="form-group">
@@ -394,8 +264,8 @@
               v-for="item in yesNoOptions"
               :key="item.value"
               class="radio-chip"
-              :class="{ 'radio-chip-on': hasPreviousWork === item.value }"
-              @click="hasPreviousWork = item.value"
+              :class="{ 'radio-chip-on': form.hasPreviousWork === item.value }"
+              @click="setHasPreviousWork(item.value)"
             >
               {{ item.label }}
             </view>
@@ -403,10 +273,12 @@
         </view>
 
         <view class="form-group">
-          <text class="form-label">验收方式</text>
-          <picker mode="selector" :range="acceptTypes" :value="acceptanceMethodIndex" @change="onAcceptanceMethodChange">
+          <text class="form-label">
+            <text class="text-red-500">*</text>验收方式
+          </text>
+          <picker mode="selector" :range="acceptanceMethods" :value="acceptanceMethodIndex" @change="onAcceptanceMethodChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="acceptanceMethod ? 'text-gray-800' : 'text-gray-400'">{{ acceptanceMethod || '请选择验收方式' }}</text>
+              <text :class="form.acceptanceMethod ? 'text-gray-800' : 'text-gray-400'">{{ form.acceptanceMethod || '请选择验收方式' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
@@ -414,9 +286,9 @@
 
         <view class="form-group">
           <text class="form-label">甲方当事人</text>
-          <view v-if="partyAContacts.length > 0" class="gap-y-2 mb-2">
+          <view v-if="form.partyAContacts.length > 0" class="gap-y-2 mb-2">
             <view
-              v-for="(contact, index) in partyAContacts"
+              v-for="(contact, index) in form.partyAContacts"
               :key="index"
               class="contact-row"
             >
@@ -440,7 +312,7 @@
 
         <view class="form-group mb-0">
           <text class="form-label">备注</text>
-          <textarea class="form-textarea" v-model="remark" placeholder="其他需要说明的事项" />
+          <textarea class="form-textarea" v-model="form.remark" placeholder="如有其他要求请备注" />
         </view>
       </view>
 
@@ -451,9 +323,10 @@
           hover-class="submit-btn-hover"
           :hover-start-time="0"
           :hover-stay-time="120"
+          :disabled="submitting"
           @click="submitTask"
         >
-          发布任务
+          {{ submitting ? '发布中...' : '发布任务' }}
         </button>
       </view>
     </view>
@@ -461,361 +334,59 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
-import { onLoad } from '@dcloudio/uni-app'
 import TopBar from '@/components/common/TopBar.vue'
 import PhotoUploader from '@/components/common/PhotoUploader.vue'
-import { useProjectStore } from '@/stores/project'
-import { createTask } from '@/api/task'
-import {
-  showToast,
-  navigateTo,
-  getCooperationTypeText,
-  getFacilityTypeText,
-  parseJsonArray
-} from '@/utils'
-import type { FacilityType } from '@/types'
-
-const projectStore = useProjectStore()
-
-const projectNames = computed(() => [...projectStore.projects.map(p => p.projectName), '+ 新增项目'])
-const projects = computed(() => projectStore.projects)
-
-const selectedProject = ref<any>(null)
-const projectIndex = computed(() => {
-  if (!selectedProject.value) return -1
-  return projects.value.findIndex(p => String(p.projectId) === String(selectedProject.value?.projectId))
-})
-
-// 项目基本信息
-const taskName = ref('')
-const taskType = ref('')
-const contactName = ref('')
-const contactPhone = ref('')
-
-// 服务信息
-const faultLocation = ref('')
-const geoLocation = ref('')
-const faultTypes = ['堵塞', '满溢', '排水慢', '异味', '其他']
-const selectedFaultTypes = ref<string[]>([])
-const customerWords = ref('')
-const faultDescription = ref('')
-const firstOccurrenceDate = ref('')
-const firstOccurrenceTime = ref('')
-const recurrenceOptions = ['偶尔', '频繁']
-const recurrenceStatus = ref('')
-const impactScopeOptions = ['单户', '楼栋', '小区主干道', '商铺门口']
-const impactScope = ref('')
-const constructionTypes = ['清淤', '排查']
-const constructionType = ref('')
-const specialRequirement = ref('')
+import { usePublishTaskForm } from '@/composables/usePublishTaskForm'
 
-// 服务与结算数据
-const expectedArrivalDate = ref('')
-const expectedArrivalTime = ref('')
-const latestDeadlineDate = ref('')
-const latestDeadlineTime = ref('')
-const acceptanceStandardOptions = ['客户自定义', '公司标准']
-const acceptanceStandard = ref('')
-const settlementMethodOptions = ['签单', '现结', '对公转账', '二维码']
-const settlementMethod = ref('')
-const blockageCount = ref('')
-const dredgingVolume = ref('')
-
-// 工单生成数据
-const constructionInstruction = ref('')
-const riskTip = ref('')
-const dispatchResultOptions = ['派单成功', '暂缓派单', '拒绝派单']
-const dispatchResult = ref('')
-const constructionFacilityOptions = ['油污', '排污']
-const constructionFacility = ref('')
-const serviceMethod = ref('')
-const vehicleOptions = ['疏通车', '吸污车', '高压清洗车']
-const suggestedVehicles = ref<string[]>([])
-const waterOptions = ['有消防水', '无消防水']
-const waterDistance = ref('')
 const yesNoOptions = [
   { label: '是', value: 1 },
   { label: '否', value: 0 }
 ]
-const outletNormal = ref<number | undefined>(undefined)
-
-// 施工助力信息
-const estimatedWorkload = ref('')
-const estimatedDuration = ref('')
-const acceptanceRequirement = ref('')
-const hasPreviousWork = ref<number | undefined>(undefined)
-const acceptTypes = ['签署验收计量单', '累计自动计费']
-const acceptanceMethod = ref('')
-const partyAContacts = ref<Array<{ name: string; phone: string; position?: string }>>([])
-const remark = ref('')
-const faultPhotos = ref<string[]>([])
-
-const latitude = ref('')
-const longitude = ref('')
-
-// 任务类型选项
-const taskTypeOptions = [
-  { label: '疏通', value: 'dredge' },
-  { label: '维修', value: 'repair' },
-  { label: '清掏', value: 'clean' },
-  { label: '检查', value: 'inspect' },
-  { label: '应急', value: 'emergency' }
-]
-const taskTypeLabels = taskTypeOptions.map(t => t.label)
-const taskTypeIndex = computed(() => taskTypeOptions.findIndex(t => t.value === taskType.value))
-const taskTypeLabel = computed(() => taskTypeOptions.find(t => t.value === taskType.value)?.label || '')
-
-const selectedFacilityText = computed(() => {
-  const list = parseJsonArray<FacilityType>(selectedProject.value?.facilityTypes)
-  return list.length > 0 ? list.map(getFacilityTypeText).join('、') : '-'
-})
-
-const selectedAddressText = computed(() => {
-  const p = selectedProject.value
-  if (!p) return ''
-  if (p.address) return p.address
-  return [p.province, p.city, p.district, p.street, p.detailAddress].filter(Boolean).join('') || '-'
-})
-
-const recurrenceIndex = computed(() => recurrenceOptions.indexOf(recurrenceStatus.value))
-const impactScopeIndex = computed(() => impactScopeOptions.indexOf(impactScope.value))
-const constructionTypeIndex = computed(() => constructionTypes.indexOf(constructionType.value))
-const settlementMethodIndex = computed(() => settlementMethodOptions.indexOf(settlementMethod.value))
-const constructionFacilityIndex = computed(() => constructionFacilityOptions.indexOf(constructionFacility.value))
-const acceptanceMethodIndex = computed(() => acceptTypes.indexOf(acceptanceMethod.value))
-
-onLoad(async (query) => {
-  if (projectStore.projects.length === 0) {
-    try { await projectStore.fetchProjects(true) } catch (e) { /* ignore */ }
-  }
-  if (query?.projectId) {
-    const project = projectStore.projects.find(p => String(p.projectId) === String(query.projectId))
-    if (project) {
-      selectProject(project)
-    }
-  }
-})
-
-function selectProject(project: any) {
-  selectedProject.value = project
-  contactName.value = project.primaryContactName || ''
-  contactPhone.value = project.primaryContactPhone || ''
-}
-
-function onProjectSelectChange(e: any) {
-  const index = e.detail.value
-  if (index >= projects.value.length) {
-    navigateTo('/subPackages/pages-common/addProject')
-    return
-  }
-  selectProject(projects.value[index])
-}
-
-function resetProjectSelection() {
-  selectedProject.value = null
-  contactName.value = ''
-  contactPhone.value = ''
-}
 
-function onTaskTypeChange(e: any) {
-  taskType.value = taskTypeOptions[Number(e.detail.value)].value
-}
-
-function toggleFaultType(type: string) {
-  const idx = selectedFaultTypes.value.indexOf(type)
-  if (idx > -1) {
-    selectedFaultTypes.value.splice(idx, 1)
-  } else {
-    selectedFaultTypes.value.push(type)
-  }
-  updateAutoInstruction()
-}
-
-function toggleVehicle(v: string) {
-  const idx = suggestedVehicles.value.indexOf(v)
-  if (idx > -1) {
-    suggestedVehicles.value.splice(idx, 1)
-  } else {
-    suggestedVehicles.value.push(v)
-  }
-}
-
-function onFirstOccurrenceDateChange(e: any) { firstOccurrenceDate.value = e.detail.value }
-function onFirstOccurrenceTimeChange(e: any) { firstOccurrenceTime.value = e.detail.value }
-function onRecurrenceChange(e: any) { recurrenceStatus.value = recurrenceOptions[Number(e.detail.value)] }
-function onImpactScopeChange(e: any) { impactScope.value = impactScopeOptions[Number(e.detail.value)] }
-function onConstructionTypeChange(e: any) { constructionType.value = constructionTypes[Number(e.detail.value)] }
-function onExpectedArrivalDateChange(e: any) { expectedArrivalDate.value = e.detail.value }
-function onExpectedArrivalTimeChange(e: any) { expectedArrivalTime.value = e.detail.value }
-function onLatestDeadlineDateChange(e: any) { latestDeadlineDate.value = e.detail.value }
-function onLatestDeadlineTimeChange(e: any) { latestDeadlineTime.value = e.detail.value }
-function onSettlementMethodChange(e: any) { settlementMethod.value = settlementMethodOptions[Number(e.detail.value)] }
-function onConstructionFacilityChange(e: any) { constructionFacility.value = constructionFacilityOptions[Number(e.detail.value)] }
-function onAcceptanceMethodChange(e: any) { acceptanceMethod.value = acceptTypes[Number(e.detail.value)] }
-
-function chooseLocation() {
-  uni.authorize({
-    scope: 'scope.userLocation',
-    success: () => {
-      uni.chooseLocation({
-        success: (res) => {
-          geoLocation.value = res.address || res.name || ''
-          latitude.value = String(res.latitude ?? '')
-          longitude.value = String(res.longitude ?? '')
-        },
-        fail: (err) => {
-          console.error('chooseLocation fail', err)
-          uni.showToast({ title: '选择位置失败', icon: 'none' })
-        }
-      })
-    },
-    fail: () => {
-      uni.showModal({
-        title: '需要位置权限',
-        content: '请在设置中允许使用位置信息',
-        success: (res) => {
-          if (res.confirm) {
-            uni.openSetting()
-          }
-        }
-      })
-    }
-  })
-}
-
-function addPartyAContact() {
-  partyAContacts.value.push({ name: '', phone: '', position: '' })
-}
-
-function removePartyAContact(index: number) {
-  partyAContacts.value.splice(index, 1)
-}
-
-function updateAutoInstruction() {
-  let instruction = ''
-  let risk = ''
-
-  if (selectedFaultTypes.value.includes('堵塞')) {
-    instruction += '使用疏通设备清除堵塞物;'
-    risk += '注意检查堵塞物性质,避免损坏管道;'
-  }
-  if (selectedFaultTypes.value.includes('满溢')) {
-    instruction += '先抽排溢出的污水,再进行清理;'
-    risk += '满溢区域可能存在滑倒风险,请设置警示标识;'
-  }
-  if (selectedFaultTypes.value.includes('异味')) {
-    instruction += '检查是否有有害气体,必要时进行通风处理;'
-    risk += '可能存在硫化氢等有毒气体,必须佩戴防护装备;'
-  }
-
-  constructionInstruction.value = instruction || '根据现场情况制定施工方案'
-  riskTip.value = risk || '注意施工安全,佩戴必要的防护装备'
-}
-
-function combineDateTime(date: string, time: string): string {
-  if (!date) return ''
-  return time ? `${date}T${time}` : `${date}T00:00:00`
-}
-
-function isRemoteUrl(url: string): boolean {
-  return /^https?:\/\//.test(url)
-}
-
-function validateForm(): boolean {
-  if (!taskName.value.trim()) { showToast('请输入任务名称'); return false }
-  if (!taskType.value) { showToast('请选择任务类型'); return false }
-  if (!selectedProject.value) { showToast('请选择项目'); return false }
-  if (!faultLocation.value.trim()) { showToast('请输入故障位置'); return false }
-  if (selectedFaultTypes.value.length === 0) { showToast('请选择故障类型'); return false }
-  if (!faultDescription.value.trim()) { showToast('请输入故障描述'); return false }
-  return true
-}
-
-async function submitTask() {
-  if (!validateForm()) return
-
-  if (faultPhotos.value.some((u) => !isRemoteUrl(u))) {
-    showToast('图片正在上传,请稍候')
-    return
-  }
-
-  const p = selectedProject.value!
-  const address = p.address || [p.province, p.city, p.district, p.street, p.detailAddress].filter(Boolean).join('') || '-'
-  const expectedArrivalTimeStr = combineDateTime(expectedArrivalDate.value, expectedArrivalTime.value)
-  const latestDeadlineStr = combineDateTime(latestDeadlineDate.value, latestDeadlineTime.value)
-  const firstOccurrenceTimeStr = combineDateTime(firstOccurrenceDate.value, firstOccurrenceTime.value)
-
-  const payload = {
-    taskNo: `TK-${Date.now()}`,
-    taskName: taskName.value.trim(),
-    projectId: p.projectId,
-    taskType: taskType.value,
-    status: 'auditing',
-    planDate: expectedArrivalTimeStr ? expectedArrivalTimeStr.split('T')[0] : new Date().toISOString().split('T')[0],
-    address,
-    remark: remark.value,
-    facilityType: selectedFacilityText.value,
-    serviceContent: faultDescription.value,
-    serviceRequirement: constructionType.value,
-    specialRequirement: specialRequirement.value,
-    urgencyLevel: 'normal',
-    planTimeRange: 'morning',
-    faultLocation: faultLocation.value,
-    faultType: selectedFaultTypes.value.join(','),
-    customerWords: customerWords.value,
-    firstOccurrenceTime: firstOccurrenceTimeStr,
-    recurrenceStatus: recurrenceStatus.value,
-    impactScope: impactScope.value,
-    constructionType: constructionType.value,
-    settlementType: settlementMethod.value === '签单' ? 'contract' : 'extra',
-    settlementAmount: 0,
-    settlementMethod: settlementMethod.value,
-    expectedArrivalTime: expectedArrivalTimeStr,
-    latestDeadline: latestDeadlineStr,
-    acceptanceStandard: acceptanceStandard.value,
-    blockageCount: blockageCount.value ? Number(blockageCount.value) : undefined,
-    dredgingVolume: dredgingVolume.value ? Number(dredgingVolume.value) : undefined,
-    workOrderNo: `GD-${Date.now()}`,
-    constructionInstruction: constructionInstruction.value,
-    riskTip: riskTip.value,
-    dispatchResult: dispatchResult.value,
-    constructionFacility: constructionFacility.value,
-    serviceMethod: serviceMethod.value,
-    waterDistance: waterDistance.value,
-    outletNormal: outletNormal.value,
-    assistantInfo: estimatedWorkload.value,
-    attentionPoint: acceptanceRequirement.value,
-    toolRequirement: suggestedVehicles.value.join(','),
-    estimatedWorkload: estimatedWorkload.value,
-    estimatedDuration: estimatedDuration.value,
-    acceptanceRequirement: acceptanceRequirement.value,
-    hasPreviousWork: hasPreviousWork.value,
-    acceptanceMethod: acceptanceMethod.value,
-    extraData: JSON.stringify({
-      cooperationType: p.cooperationType,
-      contactName: contactName.value,
-      contactPhone: contactPhone.value,
-      partyAContacts: partyAContacts.value,
-      geoLocation: geoLocation.value,
-      latitude: latitude.value,
-      longitude: longitude.value,
-      faultPhotos: faultPhotos.value,
-      projectName: p.projectName,
-      customerName: p.customerName,
-      customerPhone: p.customerPhone,
-      suggestedVehicles: suggestedVehicles.value,
-    })
-  }
-
-  try {
-    await createTask(payload)
-    showToast({ title: '任务发布成功', icon: 'success' })
-    setTimeout(() => uni.navigateBack(), 1500)
-  } catch (e) {
-    // request.ts 已统一提示错误信息
-  }
-}
+const {
+  form,
+  submitting,
+  projectNames,
+  projectIndex,
+  selectedProject,
+  servicePointNames,
+  servicePointIndex,
+  selectedServicePointText,
+  constructionTypeLabels,
+  constructionTypeIndex,
+  constructionFacilityLabels,
+  constructionFacilityIndex,
+  serviceMethodLabels,
+  serviceMethodIndex,
+  suggestedVehicleLabels,
+  suggestedVehicleOptions,
+  waterDistanceLabels,
+  waterDistanceIndex,
+  acceptanceMethods,
+  acceptanceMethodIndex,
+  serviceTimeDate,
+  serviceTimeTime,
+
+  onProjectSelectChange,
+  onServicePointChange,
+  resetProjectSelection,
+  onConstructionTypeChange,
+  onConstructionFacilityChange,
+  onServiceMethodChange,
+  onWaterDistanceChange,
+  onAcceptanceMethodChange,
+  toggleVehicle,
+  chooseLocation,
+  addContact,
+  removeContact,
+  addPartyAContact,
+  removePartyAContact,
+  onServiceTimeDateChange,
+  onServiceTimeTimeChange,
+  setOutletNormal,
+  setHasPreviousWork,
+  submitTask
+} = usePublishTaskForm()
 </script>
 
 <style scoped>
@@ -884,7 +455,7 @@ async function submitTask() {
   border: 1px solid rgba(164, 216, 152, 0.3);
 }
 
-/* 故障类型/选项标签:默认白底灰边,激活态绿色 */
+/* 选项标签:默认白底灰边,激活态绿色 */
 .fault-chip {
   padding: 5px 14px;
   border-radius: 999px;
@@ -994,6 +565,10 @@ async function submitTask() {
   opacity: 0.88;
 }
 
+.submit-btn[disabled] {
+  opacity: 0.6;
+}
+
 .map-btn {
   display: flex;
   flex-direction: column;

+ 625 - 197
src/subPackages/pages-common/taskDetail.vue

@@ -6,108 +6,251 @@
       <text class="empty-text">加载中...</text>
     </view>
 
-    <view v-else-if="task" class="px-4 pt-3">
-      <!-- 任务状态卡片 -->
-      <view class="glass-card p-4">
-        <view class="flex justify-between items-start mb-3">
-          <view class="flex-1 min-w-0 mr-2">
-            <text class="notice-title">{{ taskTitle }}</text>
-            <view class="mt-2 flex items-center">
-              <StatusTag :status="task.status" />
-              <text v-if="isEmergency" class="emergency-tag ml-2">应急</text>
-              <view v-if="canUrge" class="urge-btn ml-auto" @click="handleUrge">
-                <text class="uni-icons uniui-notification-filled urge-icon mr-1"></text>
-                <text class="urge-text">催单</text>
+    <view v-else-if="task" class="px-4 py-4 pb-8">
+      <!-- 顶部状态 -->
+      <view class="status-wrap">
+        <StatusTag :status="task.status" />
+        <text v-if="isEmergency" class="emergency-tag ml-2">应急</text>
+      </view>
+
+      <!-- 项目信息 -->
+      <view v-if="project" class="detail-block">
+        <view class="detail-block-header">
+          <text class="uni-icons uniui-home-filled header-icon text-blue-500 mr-2"></text>
+          <text class="header-title">项目信息</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">项目名称</text>
+          <text class="detail-value font-medium">{{ project.projectName || '-' }}</text>
+        </view>
+        <view v-if="coopTypeText" class="detail-row">
+          <text class="detail-label">合作类型</text>
+          <text class="detail-value">
+            <text class="tag tag-blue">{{ coopTypeText }}</text>
+          </text>
+        </view>
+        <view v-if="facilityTypeText" class="detail-row">
+          <text class="detail-label">设施类型</text>
+          <text class="detail-value">{{ facilityTypeText }}</text>
+        </view>
+        <view v-if="projectAddress" class="detail-row">
+          <text class="detail-label">项目地址</text>
+          <text class="detail-value">{{ projectAddress }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">合同总价格</text>
+          <text class="detail-value text-orange-500 font-bold">{{ contractAmountText }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">服务有效期</text>
+          <text class="detail-value">{{ servicePeriodText }}</text>
+        </view>
+        <view v-if="locationContacts.length > 0" class="detail-row detail-row-column">
+          <text class="detail-label mb-2">联系人列表</text>
+          <view class="w-full">
+            <view
+              v-for="(contact, index) in locationContacts"
+              :key="index"
+              class="contact-item"
+            >
+              <view class="flex-1">
+                <text class="text-sm text-gray-800">{{ contact.name }}</text>
+                <text v-if="contact.phone" class="text-xs text-gray-500 ml-2">{{ maskPhone(contact.phone) }}</text>
               </view>
+              <button
+                v-if="contact.phone"
+                class="phone-circle"
+                @click="callPhone(contact.phone)"
+              >
+                <text class="uni-icons uniui-phone-filled phone-icon"></text>
+              </button>
             </view>
           </view>
-          <text class="meta-text flex-shrink-0">{{ task.taskNo }}</text>
-        </view>
-        <view v-if="task.address" class="meta-line mb-2">
-          <text class="uni-icons uniui-location-filled meta-icon mr-2"></text>
-          <text class="meta-value flex-1">{{ task.address }}</text>
-        </view>
-        <view class="meta-line">
-          <text class="uni-icons uniui-clock-filled meta-icon mr-2"></text>
-          <text class="meta-value">{{ serviceTimeText }}</text>
         </view>
       </view>
 
-      <!-- 基本信息 -->
-      <view class="glass-card">
-        <view class="section-head">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title flex-1">基本信息</text>
-        </view>
-        <view class="info-row">
-          <text class="info-label">服务类型</text>
-          <text class="info-value">{{ constructionTypeText }}</text>
-        </view>
-        <view class="info-row">
-          <text class="info-label">客户名称</text>
-          <text class="info-value">{{ customerName }}</text>
-        </view>
-        <view class="info-row">
-          <text class="info-label">联系电话</text>
-          <view class="flex items-center">
-            <text class="info-value mr-2">{{ customerPhone || '-' }}</text>
-            <button
-              v-if="customerPhone"
-              class="phone-circle"
-              @click="callPhone(customerPhone)"
-            >
-              <text class="uni-icons uniui-phone-filled phone-icon"></text>
-            </button>
-          </view>
+      <!-- 施工信息 -->
+      <view class="detail-block">
+        <view class="detail-block-header">
+          <text class="uni-icons uniui-settings-filled header-icon text-green-500 mr-2"></text>
+          <text class="header-title">施工信息</text>
         </view>
-        <view class="info-row">
-          <text class="info-label">项目地址</text>
-          <text class="info-value info-value-left">{{ task.address || '-' }}</text>
+        <view v-if="serviceTimeText" class="detail-row">
+          <text class="detail-label">服务时间</text>
+          <text class="detail-value">{{ serviceTimeText }}</text>
         </view>
-        <view class="info-row">
-          <text class="info-label">预约时间</text>
-          <text class="info-value">{{ serviceTimeText }}</text>
+        <view v-if="task.faultLocation" class="detail-row">
+          <text class="detail-label">故障位置</text>
+          <text class="detail-value">{{ task.faultLocation }}</text>
         </view>
-        <view class="info-row">
-          <text class="info-label">紧急程度</text>
-          <text class="info-value" :class="isEmergency ? 'urgent-text' : 'normal-text'">
-            {{ urgencyText }}
+        <view v-if="geoLocationText" class="detail-row">
+          <text class="detail-label">地理位置</text>
+          <text class="detail-value text-blue-500" @click="openMap">
+            <text class="uni-icons uniui-location-filled mr-1"></text>
+            点击查看地图
           </text>
         </view>
+        <view v-if="faultDescription" class="detail-row detail-row-column">
+          <text class="detail-label mb-1">故障描述</text>
+          <text class="detail-value text-gray-600 text-sm text-left">{{ faultDescription }}</text>
+        </view>
+        <view v-if="faultMedia.length > 0" class="detail-row detail-row-column">
+          <text class="detail-label mb-2">故障图片/视频</text>
+          <view class="flex flex-wrap gap-2">
+            <image
+              v-for="(url, index) in faultMedia"
+              :key="index"
+              :src="url"
+              class="media-thumb"
+              mode="aspectFill"
+              @click="previewMedia(index)"
+            />
+          </view>
+        </view>
+        <view v-if="constructionTypeText" class="detail-row">
+          <text class="detail-label">施工类型</text>
+          <text class="detail-value">{{ constructionTypeText }}</text>
+        </view>
+        <view v-if="task.constructionFacility" class="detail-row">
+          <text class="detail-label">施工设施</text>
+          <text class="detail-value">{{ getFacilityTypeText(task.constructionFacility) }}</text>
+        </view>
+        <view v-if="task.serviceMethod" class="detail-row">
+          <text class="detail-label">服务方式</text>
+          <text class="detail-value">{{ getServiceMethodText(task.serviceMethod) }}</text>
+        </view>
+        <view v-if="toolRequirementText" class="detail-row">
+          <text class="detail-label">建议出车</text>
+          <text class="detail-value">{{ toolRequirementText }}</text>
+        </view>
+        <view v-if="task.waterDistance" class="detail-row">
+          <text class="detail-label">取水距离</text>
+          <text class="detail-value">{{ getWaterDistanceText(task.waterDistance) }}</text>
+        </view>
+        <view v-if="task.outletNormal !== undefined && task.outletNormal !== null" class="detail-row">
+          <text class="detail-label">化粪池排口</text>
+          <text class="detail-value">{{ task.outletNormal === 1 ? '正常' : '异常' }}</text>
+        </view>
+        <view v-if="task.assistantInfo" class="detail-row detail-row-column">
+          <text class="detail-label mb-1">施工助力信息</text>
+          <text class="detail-value text-gray-600 text-sm text-left">{{ task.assistantInfo }}</text>
+        </view>
+        <view v-if="task.estimatedWorkload" class="detail-row">
+          <text class="detail-label">预计作业量</text>
+          <text class="detail-value">{{ task.estimatedWorkload }}</text>
+        </view>
+        <view v-if="task.estimatedDuration" class="detail-row">
+          <text class="detail-label">预计工时</text>
+          <text class="detail-value">{{ task.estimatedDuration }}</text>
+        </view>
+        <view v-if="task.acceptanceRequirement" class="detail-row">
+          <text class="detail-label">验收要求</text>
+          <text class="detail-value">{{ task.acceptanceRequirement }}</text>
+        </view>
+        <view v-if="task.hasPreviousWork !== undefined && task.hasPreviousWork !== null" class="detail-row">
+          <text class="detail-label">前期施工关联</text>
+          <text class="detail-value">{{ task.hasPreviousWork === 1 ? '是' : '否' }}</text>
+        </view>
+        <view v-if="task.acceptanceMethod" class="detail-row">
+          <text class="detail-label">验收方式</text>
+          <text class="detail-value">{{ getAcceptanceMethodText(task.acceptanceMethod) }}</text>
+        </view>
+        <view v-if="partyAContacts.length > 0" class="detail-row detail-row-column">
+          <text class="detail-label mb-2">甲方当事人</text>
+          <view class="w-full">
+            <view
+              v-for="(contact, index) in partyAContacts"
+              :key="index"
+              class="contact-item"
+            >
+              <view class="flex-1">
+                <text class="text-sm text-gray-800">{{ contact.name }}</text>
+                <text v-if="contact.phone" class="text-xs text-gray-500 ml-2">{{ maskPhone(contact.phone) }}</text>
+              </view>
+              <button
+                v-if="contact.phone"
+                class="phone-circle"
+                @click="callPhone(contact.phone)"
+              >
+                <text class="uni-icons uniui-phone-filled phone-icon"></text>
+              </button>
+            </view>
+          </view>
+        </view>
+        <view v-if="task.remark" class="detail-row detail-row-column">
+          <text class="detail-label mb-1">备注</text>
+          <text class="detail-value text-red-500 text-sm text-left">{{ task.remark }}</text>
+        </view>
       </view>
 
-      <!-- 服务点信息 -->
-      <view v-if="servicePoints.length > 0" class="glass-card">
-        <view class="section-head">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title flex-1">服务点信息</text>
-        </view>
-        <view
-          v-for="(point, index) in servicePoints"
-          :key="index"
-          class="point-row"
-        >
-          <view class="flex justify-between items-center mb-2">
-            <text class="point-name">{{ point.name }}</text>
-            <text v-if="point.serviceType" class="type-tag">{{ point.serviceType }}</text>
+      <!-- 任务进度 -->
+      <view class="detail-block">
+        <view class="detail-block-header">
+          <text class="uni-icons uniui-compose header-icon text-purple-500 mr-2"></text>
+          <text class="header-title">任务进度</text>
+        </view>
+        <view class="progress-container">
+          <view class="progress-track">
+            <view class="progress-fill" :style="{ width: progressWidth }"></view>
+            <view
+              v-for="i in 4"
+              :key="i"
+              class="progress-step"
+              :class="{ 'progress-step-completed': i <= progressStep, 'progress-step-active': i === progressStep && i !== 4 }"
+            >
+            </view>
           </view>
-          <view v-if="point.address" class="flex items-center">
-            <text class="uni-icons uniui-location-filled meta-icon mr-1"></text>
-            <text class="meta-text">{{ point.address }}</text>
+          <view class="progress-labels">
+            <text
+              v-for="(label, i) in progressLabels"
+              :key="i"
+              class="progress-label"
+              :class="i < progressStep ? 'progress-label-active' : ''"
+            >{{ label }}</text>
           </view>
         </view>
       </view>
 
       <!-- 处理记录 -->
-      <view class="glass-card">
-        <view class="section-head">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title flex-1">处理记录</text>
+      <view v-if="timelineRecords.length > 0" class="detail-block">
+        <view class="detail-block-header">
+          <text class="uni-icons uniui-reload header-icon text-orange-500 mr-2"></text>
+          <text class="header-title">处理记录</text>
         </view>
-        <view class="pb-3 px-2">
+        <view class="p-4">
           <Timeline :records="timelineRecords" />
         </view>
       </view>
+
+      <!-- 操作日志 -->
+      <view v-if="taskLogs.length > 0" class="detail-block">
+        <view class="detail-block-header">
+          <text class="uni-icons uniui-list header-icon text-green-500 mr-2"></text>
+          <text class="header-title">操作日志</text>
+        </view>
+        <view class="log-list">
+          <view
+            v-for="(log, index) in taskLogs"
+            :key="log.logId || index"
+            class="log-item"
+          >
+            <text class="log-time">{{ formatDate(log.createTime, 'MM-DD HH:mm') }}</text>
+            <view class="log-content">
+              <text class="log-operator">{{ log.operatorName || '系统' }}</text>
+              <text class="log-action">{{ log.operation || '状态更新' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 底部操作按钮 -->
+      <view v-if="showActions" class="bottom-actions">
+        <view class="action-btn action-btn-cancel" @click="handleCancel">取消任务</view>
+        <view class="action-btn action-btn-urge" @click="handleUrge">
+          <text class="uni-icons uniui-notification-filled action-btn-icon"></text>
+          催单
+        </view>
+      </view>
     </view>
 
     <EmptyState v-else message="任务不存在" />
@@ -124,16 +267,16 @@ import StatusTag from '@/components/common/StatusTag.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import Timeline from '@/components/common/Timeline.vue'
 import type { TimelineRecord } from '@/components/common/Timeline.vue'
-import { getTaskDetail, urgeTask } from '@/api/task'
+import { getTaskDetail, urgeTask, cancelTask } from '@/api/task'
 import { getProjectDetail } from '@/api/project'
-import { formatDate } from '@/utils'
-import type { ServicePoint } from '@/types'
+import { formatDate, formatMoney, getCooperationTypeText, getConstructionTypeText, getFacilityTypeText, getServiceMethodText, getSuggestedVehicleText, getWaterDistanceText, getAcceptanceMethodText, parseJsonArray } from '@/utils'
+import type { Project, LocationContact } from '@/types'
 
 const taskStore = useTaskStore()
 const authStore = useAuthStore()
 
 const task = ref<any>(null)
-const project = ref<any>(null)
+const project = ref<Project | null>(null)
 const loading = ref(false)
 
 const extra = computed(() => {
@@ -145,63 +288,148 @@ const extra = computed(() => {
   }
 })
 
-const taskTitle = computed(() => {
-  const t = task.value
-  if (!t) return ''
-  return t.taskName || extra.value.projectName || t.faultLocation || '未命名任务'
-})
-
 const isEmergency = computed(() => {
   const t = task.value
   if (!t) return false
-  return t.taskType === 'emergency' || ['urgent', 'very_urgent'].includes(t.urgencyLevel)
-})
-
-const urgencyText = computed(() => {
-  const map: Record<string, string> = { normal: '普通', urgent: '紧急', very_urgent: '特急' }
-  const level = task.value?.urgencyLevel
-  return level ? map[level] || level : '普通'
+  return t.taskType === 'emergency' || t.serviceMethod === '应急' || ['urgent', 'very_urgent'].includes(t.urgencyLevel)
 })
 
 const constructionTypeText = computed(() => {
   const t = task.value
   if (!t) return '-'
   const map: Record<string, string> = { dredge: '疏通', clean: '清掏', inspect: '排查', repair: '维修', emergency: '应急' }
-  return t.constructionType || (t.taskType ? map[t.taskType] || t.taskType : '-')
+  const raw = t.constructionType || (t.taskType ? map[t.taskType] || t.taskType : '')
+  return raw ? getConstructionTypeText(raw) : '-'
 })
 
 const serviceTimeText = computed(() => {
   const t = task.value
   if (!t) return '待定'
+  const time = t.serviceTime || t.expectedArrivalTime || ''
+  if (time) return formatDate(time, 'YYYY-MM-DD HH:mm')
   const date = t.planDate || ''
   const rangeMap: Record<string, string> = { morning: '上午', afternoon: '下午', evening: '晚上' }
   const range = rangeMap[t.planTimeRange] || t.planTimeRange || ''
   return date || range ? `${date} ${range}`.trim() : '待定'
 })
 
-const customerName = computed(() => {
-  return project.value?.customerName || extra.value.customerName || project.value?.primaryContactName || '-'
+const coopTypeText = computed(() => {
+  return project.value?.cooperationType ? getCooperationTypeText(project.value.cooperationType) : ''
 })
 
-const customerPhone = computed(() => {
-  return project.value?.customerPhone || extra.value.customerPhone || project.value?.primaryContactPhone || ''
+const facilityTypeText = computed(() => {
+  const raw = project.value?.facilityTypes || task.value?.facilityType
+  if (!raw) return ''
+  const list = parseJsonArray<string>(raw)
+  if (list.length > 0) {
+    return list.map(t => getFacilityTypeText(t)).join('、')
+  }
+  return typeof raw === 'string' ? raw : ''
 })
 
-const canUrge = computed(() => {
-  return task.value && !['completed', 'cancelled'].includes(task.value.status)
+const projectAddress = computed(() => {
+  if (!project.value) return task.value?.address || ''
+  const p = project.value
+  return [p.province, p.city, p.district, p.street, p.detailAddress || p.address].filter(Boolean).join('') || p.address || task.value?.address || ''
+})
+
+const contractAmountText = computed(() => {
+  const amount = project.value?.contractAmount
+  return amount !== undefined && amount !== null ? formatMoney(amount) : '-'
 })
 
-const servicePoints = computed<ServicePoint[]>(() => {
-  const raw = project.value?.servicePoints
+const servicePeriodText = computed(() => {
+  const p = project.value
+  if (!p) return '-'
+  const start = p.serviceStartDate || ''
+  const end = p.serviceEndDate || ''
+  if (start && end) return `${start} 至 ${end}`
+  return start || end || '-'
+})
+
+const locationContacts = computed<LocationContact[]>(() => {
+  const raw = project.value?.locationContacts
   if (!raw) return []
-  try {
-    const list = typeof raw === 'string' ? JSON.parse(raw) : raw
-    return Array.isArray(list) ? list : []
-  } catch {
-    return []
+  return parseJsonArray<LocationContact>(raw)
+})
+
+const faultDescription = computed(() => {
+  return task.value?.serviceContent || task.value?.faultDesc || task.value?.faultDescription || ''
+})
+
+const geoLocationText = computed(() => {
+  if (extra.value.geoLocation) return extra.value.geoLocation
+  if (task.value?.geoLocation) return task.value.geoLocation
+  if (extra.value.latitude && extra.value.longitude) return `${extra.value.latitude}, ${extra.value.longitude}`
+  return ''
+})
+
+const faultMedia = computed<string[]>(() => {
+  const photos = extra.value.faultPhotos || extra.value.photos || extra.value.media
+  if (!photos) return []
+  if (Array.isArray(photos)) return photos
+  return String(photos).split(',').filter(Boolean)
+})
+
+const toolRequirementText = computed(() => {
+  const raw = task.value?.toolRequirement || extra.value.suggestedVehicle || extra.value.toolRequirement || ''
+  return raw ? getSuggestedVehicleText(raw) : ''
+})
+
+const partyAContacts = computed<Array<{ name: string; phone: string }>>(() => {
+  const list = task.value?.partyAContacts || extra.value.partyAContacts || extra.value.partyA
+  if (!list) return []
+  if (Array.isArray(list)) return list
+  return []
+})
+
+const canUrge = computed(() => {
+  return task.value && !['completed', 'cancelled', 'rejected'].includes(task.value.status)
+})
+
+const showActions = computed(() => {
+  return task.value && !['completed', 'cancelled', 'rejected'].includes(task.value.status)
+})
+
+const progressLabels = ['发布', '调度确认', '施工', '完成']
+const progressStep = computed(() => {
+  const status = task.value?.status
+  if (!status) return 0
+  switch (status) {
+    case 'pending':
+    case 'auditing':
+    case 'rejected':
+    case 'cancelled':
+      return 1
+    case 'approved':
+      return 2
+    case 'assigned':
+    case 'in_progress':
+    case 'confirmed':
+    case 'scheduled':
+    case 'departed':
+    case 'arrived':
+    case 'constructing':
+    case 'inspecting':
+      return 3
+    case 'completed':
+      return 4
+    default:
+      return 1
   }
 })
 
+const progressWidth = computed(() => {
+  const step = progressStep.value
+  if (step <= 1) return '0%'
+  if (step >= 4) return '100%'
+  // 4 个节点分成 3 段
+  const positions = ['0%', '0%', '50%', '100%']
+  return positions[step - 1] || '0%'
+})
+
+const taskLogs = computed(() => task.value?.logList || [])
+
 const timelineRecords = computed<TimelineRecord[]>(() => {
   const logs: any[] = task.value?.logList || []
   return logs.map(log => ({
@@ -223,11 +451,30 @@ function roleFromOperationType(type: string): string {
   return map[type] || 'construction'
 }
 
+function maskPhone(phone: string): string {
+  if (!phone) return ''
+  if (phone.length === 11) {
+    return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
+  }
+  return phone
+}
+
 function callPhone(phone: string) {
   if (!phone) return
   uni.makePhoneCall({ phoneNumber: phone })
 }
 
+function openMap() {
+  const lat = Number(extra.value.latitude)
+  const lng = Number(extra.value.longitude)
+  if (!lat || !lng) return
+  uni.openLocation({ latitude: lat, longitude: lng, name: task.value?.faultLocation || '故障位置' })
+}
+
+function previewMedia(index: number) {
+  uni.previewImage({ urls: faultMedia.value, current: index })
+}
+
 async function handleUrge() {
   if (!task.value?.taskId) return
   const res = await new Promise<any>((resolve) => {
@@ -253,11 +500,44 @@ async function handleUrge() {
   }
 }
 
+async function handleCancel() {
+  if (!task.value?.taskId) return
+  const res = await new Promise<any>((resolve) => {
+    uni.showModal({
+      title: '取消任务',
+      editable: true,
+      placeholderText: '请输入取消原因(必填)',
+      confirmText: '确认取消',
+      cancelText: '暂不取消',
+      success: resolve,
+      fail: () => resolve({ confirm: false }),
+    })
+  })
+  if (!res.confirm) return
+  const reason = (res.content || '').trim()
+  if (!reason) {
+    uni.showToast({ title: '取消原因不能为空', icon: 'none' })
+    return
+  }
+  try {
+    await cancelTask(
+      task.value.taskId,
+      reason,
+      authStore.user?.id ? Number(authStore.user.id) : 0,
+      authStore.user?.name || ''
+    )
+    uni.showToast({ title: '任务已取消', icon: 'success' })
+    loadDetail(task.value.taskId)
+  } catch (error) {
+    console.error('取消任务失败', error)
+    uni.showToast({ title: '取消任务失败', icon: 'none' })
+  }
+}
+
 onLoad((options) => {
   if (options?.id) {
     loadDetail(Number(options.id))
   } else if (taskStore.currentTask) {
-    // 兼容未携带 id 的入口:使用列表中已加载的任务数据
     task.value = taskStore.currentTask
     loadProject(taskStore.currentTask.projectId)
   }
@@ -279,7 +559,7 @@ async function loadDetail(taskId: number) {
 function loadProject(projectId: number | string | undefined) {
   if (!projectId) return
   getProjectDetail(projectId)
-    .then(p => { project.value = p })
+    .then(p => { project.value = p as Project })
     .catch(() => {})
 }
 </script>
@@ -289,129 +569,187 @@ function loadProject(projectId: number | string | undefined) {
   max-width: 430px;
   margin: 0 auto;
   min-height: 100vh;
-  background-color: #f6fbf9;
-  padding-bottom: 24px;
+  background-color: #f6f7f9;
+  padding-bottom: 80px;
 }
 
-.glass-card {
-  background-color: rgba(255, 255, 255, 0.85);
-  backdrop-filter: blur(14px);
-  -webkit-backdrop-filter: blur(14px);
-  border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
-  box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
-  overflow: hidden;
-  margin-bottom: 12px;
+.status-wrap {
+  display: flex;
+  align-items: center;
+  padding: 12px 0;
 }
 
-.notice-title {
-  display: block;
-  font-size: 17px;
-  font-weight: 700;
-  color: #1f2937;
-  line-height: 1.4;
+.emergency-tag {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #dc2626;
+  background-color: #fef2f2;
 }
 
-.emergency-tag {
-  padding: 2px 10px;
-  border-radius: 999px;
-  font-size: 11px;
-  font-weight: 600;
-  color: #ef4444;
-  background-color: rgba(239, 68, 68, 0.1);
+.bottom-actions {
+  position: fixed;
+  bottom: 0;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 100%;
+  max-width: 430px;
+  background-color: #ffffff;
+  border-top: 1px solid #f0f0f0;
+  padding: 12px 16px;
+  display: flex;
+  gap: 12px;
+  z-index: 20;
+  box-sizing: border-box;
 }
 
-.meta-line {
+.action-btn {
+  flex: 1;
+  padding: 12px 0;
+  border-radius: 12px;
+  text-align: center;
+  font-size: 15px;
+  font-weight: 500;
   display: flex;
   align-items: center;
+  justify-content: center;
 }
 
-.meta-icon {
-  font-size: 14px;
-  color: #9ca3af;
+.action-btn-cancel {
+  color: #666666;
+  background-color: #ffffff;
+  border: 1px solid #e5e7eb;
 }
 
-.meta-text {
-  font-size: 12px;
-  color: #9ca3af;
+.action-btn-urge {
+  color: #ffffff;
+  background-color: #3b82f6;
 }
 
-.meta-value {
-  font-size: 13px;
-  color: #4b5563;
+.action-btn-icon {
+  font-size: 15px;
+  margin-right: 4px;
 }
 
-.section-head {
+.detail-block {
+  background-color: #ffffff;
+  border-radius: 12px;
+  margin-bottom: 12px;
+  overflow: hidden;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+}
+
+.detail-block-header {
+  padding: 12px 16px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #333333;
+  border-bottom: 1px solid #f0f0f0;
   display: flex;
   align-items: center;
-  padding: 16px 16px 12px;
 }
 
-.section-bar {
-  width: 4px;
-  height: 16px;
-  border-radius: 2px;
-  background: linear-gradient(180deg, #368f6f 0%, #5ab8d0 100%);
+.header-icon {
+  font-size: 16px;
 }
 
-.section-title {
-  font-size: 16px;
-  font-weight: 700;
-  color: #1f2937;
+.header-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #333333;
 }
 
-.info-row {
+.detail-row {
+  padding: 12px 16px;
   display: flex;
-  align-items: center;
   justify-content: space-between;
-  padding: 12px 16px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
+  align-items: flex-start;
+  border-bottom: 1px solid #f5f5f5;
 }
 
-.info-label {
-  font-size: 13px;
-  color: #9ca3af;
+.detail-row:last-child {
+  border-bottom: none;
+}
+
+.detail-row-column {
+  flex-direction: column;
+  align-items: flex-start;
+}
+
+.detail-label {
+  color: #666666;
+  font-size: 14px;
   flex-shrink: 0;
   margin-right: 12px;
 }
 
-.info-value {
-  font-size: 13px;
-  color: #1f2937;
-  text-align: right;
+.detail-row-column .detail-label {
+  margin-right: 0;
 }
 
-.info-value-left {
+.detail-value {
+  color: #333333;
+  font-size: 14px;
+  text-align: right;
   flex: 1;
+  margin-left: 12px;
+}
+
+.detail-row-column .detail-value {
+  margin-left: 0;
+  width: 100%;
+}
+
+.text-blue-500 {
+  color: #3b82f6;
 }
 
-.urgent-text {
+.text-red-500 {
   color: #ef4444;
-  font-weight: 600;
 }
 
-.normal-text {
-  color: #368f6f;
+.text-orange-500 {
+  color: #f97316;
 }
 
-.point-row {
-  padding: 13px 16px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
+.font-bold {
+  font-weight: 700;
 }
 
-.point-name {
-  font-size: 14px;
-  font-weight: 600;
-  color: #1f2937;
+.text-gray-600 {
+  color: #4b5563;
 }
 
-.type-tag {
-  padding: 2px 10px;
-  border-radius: 999px;
-  font-size: 11px;
-  font-weight: 600;
-  color: #368f6f;
-  background-color: rgba(164, 216, 152, 0.24);
+.text-left {
+  text-align: left;
+}
+
+.tag {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.tag-blue {
+  background-color: #e6f0ff;
+  color: #2563eb;
+}
+
+.contact-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 0;
+  border-bottom: 1px solid #f5f5f5;
+}
+
+.contact-item:last-child {
+  border-bottom: none;
+  padding-bottom: 0;
 }
 
 .phone-circle {
@@ -425,28 +763,118 @@ function loadProject(projectId: number | string | undefined) {
   background-color: rgba(164, 216, 152, 0.24);
 }
 
+.phone-circle::after {
+  border: none;
+}
+
 .phone-icon {
   font-size: 13px;
   color: #368f6f;
 }
 
-.urge-btn {
+.media-thumb {
+  width: 64px;
+  height: 64px;
+  border-radius: 8px;
+  background-color: #f3f4f6;
+}
+
+.progress-container {
+  padding: 16px;
+}
+
+.progress-track {
+  height: 4px;
+  background-color: #e5e7eb;
+  border-radius: 2px;
+  position: relative;
   display: flex;
+  justify-content: space-between;
   align-items: center;
-  padding: 4px 10px;
-  border-radius: 999px;
-  background-color: rgba(239, 68, 68, 0.1);
 }
 
-.urge-icon {
+.progress-fill {
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  background-color: #3b82f6;
+  border-radius: 2px;
+  transition: width 0.3s ease;
+}
+
+.progress-step {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  background-color: #e5e7eb;
+  border: 2px solid #ffffff;
+  position: relative;
+  z-index: 1;
+  flex-shrink: 0;
+}
+
+.progress-step-completed {
+  background-color: #3b82f6;
+}
+
+.progress-step-active {
+  background-color: #3b82f6;
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
+}
+
+.progress-labels {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 8px;
+}
+
+.progress-label {
+  font-size: 11px;
+  color: #999999;
+  text-align: center;
+  flex: 1;
+}
+
+.progress-label-active {
+  color: #3b82f6;
+  font-weight: 500;
+}
+
+.log-list {
+  padding: 12px 16px;
+}
+
+.log-item {
+  display: flex;
+  align-items: flex-start;
+  padding: 10px 0;
+  border-bottom: 1px solid #f5f5f5;
   font-size: 13px;
-  color: #ef4444;
 }
 
-.urge-text {
-  font-size: 12px;
-  font-weight: 600;
-  color: #ef4444;
+.log-item:last-child {
+  border-bottom: none;
+}
+
+.log-time {
+  color: #999999;
+  width: 72px;
+  flex-shrink: 0;
+}
+
+.log-content {
+  flex: 1;
+  margin-left: 8px;
+}
+
+.log-operator {
+  color: #333333;
+}
+
+.log-action {
+  color: #666666;
+  margin-left: 4px;
 }
 
 .empty-text {

+ 265 - 110
src/subPackages/pages-construction/dailyTask.vue

@@ -1,6 +1,6 @@
 <template>
   <view class="sub-page">
-    <TopBar title="每日任务" show-back />
+    <TopBar title="每日检查" show-back />
 
     <view class="px-4 pt-3">
       <!-- 今日任务概览 -->
@@ -59,6 +59,16 @@
         </view>
 
         <view v-if="!todayRecord">
+          <view class="form-row mb-3">
+            <text class="form-label">检查日期</text>
+            <picker mode="date" :value="selectedCheckDate" @change="onDateChange">
+              <view class="picker-value">
+                <text>{{ selectedCheckDate || '请选择检查日期' }}</text>
+                <text class="uni-icons uniui-arrowright arrow-icon"></text>
+              </view>
+            </picker>
+          </view>
+
           <view class="form-row mb-3">
             <text class="form-label">检查车辆</text>
             <picker
@@ -79,47 +89,36 @@
             </view>
           </view>
 
-          <view class="check-group">
-            <text class="check-group-title">常规检查</text>
-            <view
-              v-for="item in checkItems"
-              :key="item.key"
-              class="check-row"
-              @click="toggleCheck(item.key)"
-            >
-              <text class="check-label">{{ item.label }}</text>
-              <view class="check-circle" :class="{ 'check-circle-on': checks[item.key] }">
-                <text v-if="checks[item.key]" class="uni-icons uniui-checkmarkempty check-tick"></text>
+          <view v-for="cat in uploadCategories" :key="cat" class="upload-group">
+            <text class="check-group-title">{{ categoryLabels[cat] }}</text>
+            <view class="upload-grid">
+              <view
+                v-for="(item, idx) in categoryFiles[cat]"
+                :key="item.url"
+                class="media-item"
+              >
+                <image
+                  v-if="item.type === 'image'"
+                  :src="item.url"
+                  class="media-thumb"
+                  mode="aspectFill"
+                  @click="previewMedia(cat, idx)"
+                />
+                <view v-else class="media-thumb media-video" @click="previewMedia(cat, idx)">
+                  <text class="uni-icons uniui-videocam media-video-icon"></text>
+                </view>
+                <view class="media-delete" @click="removeFile(cat, idx)">
+                  <text class="uni-icons uniui-close"></text>
+                </view>
               </view>
-            </view>
-          </view>
 
-          <view class="check-group">
-            <text class="check-group-title">设备检查</text>
-            <view
-              v-for="item in equipmentItems"
-              :key="item.key"
-              class="check-row"
-              @click="toggleCheck(item.key)"
-            >
-              <text class="check-label">{{ item.label }}</text>
-              <view class="check-circle" :class="{ 'check-circle-on': checks[item.key] }">
-                <text v-if="checks[item.key]" class="uni-icons uniui-checkmarkempty check-tick"></text>
-              </view>
-            </view>
-          </view>
-
-          <view class="check-group">
-            <text class="check-group-title">三大系统检查</text>
-            <view
-              v-for="item in systemItems"
-              :key="item.key"
-              class="check-row"
-              @click="toggleCheck(item.key)"
-            >
-              <text class="check-label">{{ item.label }}</text>
-              <view class="check-circle" :class="{ 'check-circle-on': checks[item.key] }">
-                <text v-if="checks[item.key]" class="uni-icons uniui-checkmarkempty check-tick"></text>
+              <view
+                v-if="categoryFiles[cat].length < maxFileCount"
+                class="media-add"
+                @click="chooseMedia(cat)"
+              >
+                <text class="uni-icons uniui-camera-filled media-add-icon"></text>
+                <text class="media-add-text">{{ categoryFiles[cat].length }}/{{ maxFileCount }}</text>
               </view>
             </view>
           </view>
@@ -152,8 +151,16 @@
           </view>
           <text class="done-text">今日车辆检查已完成</text>
           <text v-if="todayRecord.vehicleNo" class="done-meta">检查车辆:{{ todayRecord.vehicleNo }}</text>
-          <text v-if="remark" class="done-meta">备注:{{ remark }}</text>
-          <text class="done-meta">{{ today }}</text>
+          <text v-if="mediaSummary.photos || mediaSummary.videos" class="done-meta">
+            已上传:{{ mediaSummary.photos }} 张照片 / {{ mediaSummary.videos }} 个视频
+          </text>
+          <text v-if="todayRecord.remark" class="done-meta">备注:{{ todayRecord.remark }}</text>
+          <text class="done-meta">{{ todayRecord.checkDate || today }}</text>
+
+          <view class="reset-link" @click="handleReset">
+            <text class="uni-icons uniui-reload reset-icon mr-1"></text>
+            <text class="reset-text">重新检查</text>
+          </view>
         </view>
       </view>
 
@@ -198,14 +205,15 @@
 import { ref, computed, onMounted } from 'vue'
 import { useTaskStore } from '../../stores/task'
 import { useAuthStore } from '../../stores/auth'
+import { chooseMediaCompat } from '../../utils'
 import {
   getTodaySchedule,
   getTodayCheckRecord,
   submitDailyCheck,
   type DailyCheckScheduleToday,
   type DailyCheckRecord,
-  type DailyCheckItem,
 } from '../../api/dailyCheck'
+import { uploadFile } from '../../api/file'
 import TopBar from '../../components/common/TopBar.vue'
 import StatusTag from '../../components/common/StatusTag.vue'
 import EmptyState from '../../components/common/EmptyState.vue'
@@ -216,43 +224,34 @@ const authStore = useAuthStore()
 const today = new Date().toISOString().split('T')[0]
 const remarkKey = `vehicle_check_remark_${today}`
 
-const checkItems = [
-  { key: 'v1', label: '车辆外观无损坏' },
-  { key: 'v2', label: '轮胎气压正常' },
-  { key: 'v3', label: '机油液位正常' },
-  { key: 'v4', label: '冷却液液位正常' },
-  { key: 'v5', label: '灯光系统正常' },
-  { key: 'v6', label: '刹车系统正常' },
-  { key: 'v7', label: '车辆清洁' },
-]
-
-const equipmentItems = [
-  { key: 'e1', label: '高压清洗设备' },
-  { key: 'e2', label: '吸污设备' },
-  { key: 'e3', label: '疏通设备' },
-  { key: 'e4', label: '安全防护设备' },
-  { key: 'e5', label: '应急工具包' },
-  { key: 'e6', label: '通讯设备' },
-]
-
-const systemItems = [
-  { key: 's1', label: 'GPS定位系统' },
-  { key: 's2', label: '视频监控系统' },
-  { key: 's3', label: '对讲系统' },
-]
-
-const allItems = [...checkItems, ...equipmentItems, ...systemItems]
+interface MediaFile {
+  url: string
+  type: 'image' | 'video'
+}
+
+const categoryLabels: Record<string, string> = {
+  vehicle: '车辆检查',
+  equipment: '设备检查',
+  system: '三大系统检查',
+}
+const uploadCategories = ['vehicle', 'equipment', 'system']
+const maxFileCount = 9
 
 const scheduleLoading = ref(false)
 const submitting = ref(false)
 const schedule = ref<DailyCheckScheduleToday['schedule']>(null)
 const vehicles = ref<DailyCheckScheduleToday['vehicles']>([])
 const selectedVehicleIndex = ref(0)
-const checks = ref<Record<string, boolean>>({})
+const selectedCheckDate = ref(today)
+const categoryFiles = ref<Record<string, MediaFile[]>>({
+  vehicle: [],
+  equipment: [],
+  system: [],
+})
 const remark = ref('')
 const todayRecord = ref<DailyCheckRecord | null>(null)
 
-const userId = computed(() => authStore.user?.id ? Number(authStore.user.id) : 0)
+const userId = computed(() => (authStore.user?.id ? Number(authStore.user.id) : 0))
 const userName = computed(() => authStore.user?.name || '')
 
 const vehicleOptions = computed(() => {
@@ -271,8 +270,7 @@ const vehiclePlateNos = computed(() => {
 })
 
 const canSubmit = computed(() => {
-  if (!selectedVehicle.value) return false
-  return allItems.every((item) => checks.value[item.key])
+  return !!(selectedCheckDate.value && selectedVehicle.value)
 })
 
 const completedTasks = computed(() => {
@@ -285,6 +283,12 @@ const todayTasks = computed<any[]>(() => {
   )
 })
 
+const mediaSummary = computed(() => {
+  const photos = parseUrlList(todayRecord.value?.photoUrls).length
+  const videos = parseUrlList(todayRecord.value?.videoUrls).length
+  return { photos, videos }
+})
+
 function shiftTypeLabel(shiftType?: string) {
   const map: Record<string, string> = {
     day: '白班',
@@ -295,9 +299,21 @@ function shiftTypeLabel(shiftType?: string) {
   return map[shiftType || ''] || shiftType || '-'
 }
 
-function toggleCheck(type: string) {
-  if (todayRecord.value) return
-  checks.value[type] = !checks.value[type]
+function parseUrlList(value: unknown): string[] {
+  if (!value) return []
+  if (Array.isArray(value)) return value.filter(Boolean)
+  if (typeof value === 'string') {
+    try {
+      return JSON.parse(value)
+    } catch {
+      return []
+    }
+  }
+  return []
+}
+
+function onDateChange(e: any) {
+  selectedCheckDate.value = e.detail.value || today
 }
 
 async function loadSchedule() {
@@ -329,9 +345,80 @@ function onVehicleChange(e: any) {
   selectedVehicleIndex.value = e.detail.value || 0
 }
 
+function chooseMedia(category: string) {
+  const remain = maxFileCount - categoryFiles.value[category].length
+  if (remain <= 0) {
+    uni.showToast({ title: `最多上传${maxFileCount}个`, icon: 'none' })
+    return
+  }
+
+  chooseMediaCompat({
+    count: remain,
+    mediaType: ['image', 'video'],
+    sourceType: ['album', 'camera'],
+    success: async (res: any) => {
+      const tempFiles = res.tempFiles || []
+      const newItems: MediaFile[] = tempFiles.map((f: any) => ({
+        url: f.tempFilePath,
+        type: f.fileType || 'image',
+      }))
+      categoryFiles.value[category] = [...categoryFiles.value[category], ...newItems]
+
+      let allSuccess = true
+      for (const f of tempFiles) {
+        const ok = await uploadOne(category, f.tempFilePath, f.fileType || 'image')
+        if (!ok) allSuccess = false
+      }
+      if (allSuccess && tempFiles.length) {
+        uni.showToast({ title: '上传成功', icon: 'success' })
+      }
+    },
+  })
+}
+
+async function uploadOne(category: string, filePath: string, type: string): Promise<boolean> {
+  try {
+    const url = await uploadFile(filePath)
+    const list = categoryFiles.value[category]
+    const idx = list.findIndex((item) => item.url === filePath)
+    if (idx >= 0) {
+      list[idx] = { url, type: type as 'image' | 'video' }
+      categoryFiles.value[category] = [...list]
+      return true
+    }
+    return false
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '上传失败', icon: 'none' })
+    categoryFiles.value[category] = categoryFiles.value[category].filter((item) => item.url !== filePath)
+    return false
+  }
+}
+
+function removeFile(category: string, idx: number) {
+  const list = [...categoryFiles.value[category]]
+  list.splice(idx, 1)
+  categoryFiles.value[category] = list
+}
+
+function previewMedia(category: string, idx: number) {
+  const items = categoryFiles.value[category]
+  const item = items[idx]
+  if (!item) return
+
+  if (item.type === 'image') {
+    const urls = items.filter((i) => i.type === 'image').map((i) => i.url)
+    uni.previewImage({ urls, current: item.url })
+  } else {
+    const sources = items
+      .filter((i) => i.type === 'video')
+      .map((i) => ({ url: i.url, type: 'video' as const }))
+    uni.previewMedia({ sources, current: sources.findIndex((s) => s.url === item.url) })
+  }
+}
+
 async function submitCheck() {
   if (!canSubmit.value) {
-    uni.showToast({ title: '请选择车辆并完成所有检查项', icon: 'none' })
+    uni.showToast({ title: '请选择检查日期和车辆', icon: 'none' })
     return
   }
   if (!selectedVehicle.value) {
@@ -339,21 +426,20 @@ async function submitCheck() {
     return
   }
 
-  const checkItemsData: DailyCheckItem[] = allItems.map((item) => ({
-    id: item.key,
-    category: item.key.startsWith('v') ? 'vehicle' : item.key.startsWith('e') ? 'equipment' : 'system',
-    name: item.label,
-    required: true,
-    checked: !!checks.value[item.key],
-  }))
+  const allFiles = uploadCategories.flatMap((c) => categoryFiles.value[c])
+  const photoUrls = allFiles.filter((f) => f.type === 'image').map((f) => f.url)
+  const videoUrls = allFiles.filter((f) => f.type === 'video').map((f) => f.url)
 
   const payload: DailyCheckRecord = {
-    checkDate: today,
+    checkDate: selectedCheckDate.value,
     vehicleId: selectedVehicle.value.value,
     vehicleNo: selectedVehicle.value.label.split(' ')[0],
     userId: userId.value,
     userName: userName.value,
-    checkItems: checkItemsData,
+    checkStatus: 'normal',
+    checkItems: [],
+    photoUrls,
+    videoUrls,
     remark: remark.value,
   }
 
@@ -371,16 +457,20 @@ async function submitCheck() {
   }
 }
 
+function handleReset() {
+  todayRecord.value = null
+  categoryFiles.value = { vehicle: [], equipment: [], system: [] }
+  remark.value = ''
+  selectedCheckDate.value = today
+  selectedVehicleIndex.value = 0
+}
+
 function goToDetail(taskId: string | number) {
   if (!taskId) return
   uni.navigateTo({ url: `/subPackages/pages-construction/taskDetail?id=${taskId}` })
 }
 
 onMounted(() => {
-  allItems.forEach((item) => {
-    checks.value[item.key] = false
-  })
-
   const savedRemark = uni.getStorageSync(remarkKey)
   if (savedRemark) {
     remark.value = savedRemark
@@ -515,7 +605,7 @@ onMounted(() => {
   color: #9ca3af;
 }
 
-/* 检查 */
+/* 检查状态 */
 .check-state {
   font-size: 12px;
   font-weight: 600;
@@ -561,13 +651,14 @@ onMounted(() => {
   color: #9ca3af;
 }
 
-.check-group {
-  margin-bottom: 12px;
+/* 上传分组 */
+.upload-group {
+  margin-bottom: 16px;
   padding-top: 12px;
   border-top: 1px solid rgba(164, 216, 152, 0.18);
 }
 
-.check-group:first-of-type {
+.upload-group:first-of-type {
   border-top: none;
   padding-top: 0;
 }
@@ -580,36 +671,80 @@ onMounted(() => {
   color: #6b7280;
 }
 
-.check-row {
+.upload-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+
+.media-item {
+  position: relative;
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 16rpx;
+  overflow: hidden;
+  border: 1rpx solid #e5e7eb;
+}
+
+.media-thumb {
+  width: 160rpx;
+  height: 160rpx;
+  display: block;
+  object-fit: cover;
+}
+
+.media-video {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  padding: 8px 0;
+  justify-content: center;
+  background-color: #f3f4f6;
 }
 
-.check-label {
-  font-size: 14px;
-  color: #4b5563;
+.media-video-icon {
+  font-size: 36rpx;
+  color: #ef4444;
 }
 
-.check-circle {
-  width: 24px;
-  height: 24px;
+.media-delete {
+  position: absolute;
+  top: -8rpx;
+  right: -8rpx;
+  width: 36rpx;
+  height: 36rpx;
   border-radius: 50%;
-  border: 2px solid #d1d5db;
+  background-color: #ef4444;
   display: flex;
   align-items: center;
   justify-content: center;
+  z-index: 10;
 }
 
-.check-circle-on {
-  border-color: #368f6f;
-  background: linear-gradient(135deg, #368f6f 0%, #4ba98a 100%);
+.media-delete text {
+  font-size: 22rpx;
+  color: #ffffff;
 }
 
-.check-tick {
-  font-size: 13px;
-  color: #ffffff;
+.media-add {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 16rpx;
+  border: 2rpx dashed #d1d5db;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background-color: #f9fafb;
+}
+
+.media-add-icon {
+  font-size: 40rpx;
+  color: #9ca3af;
+}
+
+.media-add-text {
+  font-size: 22rpx;
+  color: #9ca3af;
+  margin-top: 6rpx;
 }
 
 .form-textarea {
@@ -679,6 +814,26 @@ onMounted(() => {
   color: #9ca3af;
 }
 
+.reset-link {
+  display: inline-flex;
+  align-items: center;
+  margin-top: 16px;
+  padding: 8px 16px;
+  border-radius: 999rpx;
+  background-color: rgba(164, 216, 152, 0.2);
+}
+
+.reset-icon {
+  font-size: 24rpx;
+  color: #368f6f;
+}
+
+.reset-text {
+  font-size: 13px;
+  color: #368f6f;
+  font-weight: 600;
+}
+
 /* 列表行 */
 .list-row {
   display: flex;

Dosya farkı çok büyük olduğundan ihmal edildi
+ 405 - 492
src/subPackages/pages-construction/taskDetail.vue


+ 4 - 5
src/subPackages/pages-dispatch/availableVehicles.vue

@@ -55,7 +55,7 @@
           <view class="flex items-center justify-between">
             <view class="flex items-center flex-1 min-w-0 mr-2">
               <view class="icon-chip mr-3">
-                <text class="uni-icons uniui-cart-filled chip-glyph"></text>
+                <text class="uni-icons uniui-gear-filled chip-glyph"></text>
               </view>
               <view class="min-w-0">
                 <text class="row-title truncate">{{ vehicle.plateNumber }}</text>
@@ -159,11 +159,10 @@ function callPhone(phone: string) {
   uni.makePhoneCall({ phoneNumber: phone })
 }
 
-onMounted(() => {
+onMounted(async () => {
   loading.value = true
-  setTimeout(() => {
-    loading.value = false
-  }, 300)
+  await vehicleStore.fetchVehicles()
+  loading.value = false
 })
 </script>
 

+ 1 - 1
src/subPackages/pages-dispatch/emergencyStandby.vue

@@ -21,7 +21,7 @@
 
       <!-- 待命说明 -->
       <view class="notice-banner">
-        <text class="uni-icons uniui-notification-filled notice-icon mr-2"></text>
+        <text class="uni-icons uniui-notification notice-icon mr-2"></text>
         <text class="notice-text">应急待命人员保持通讯畅通,随时准备响应紧急任务</text>
       </view>
 

+ 1 - 1
src/subPackages/pages-dispatch/emergencyTeam.vue

@@ -52,7 +52,7 @@
             @click="goToTaskDetail(task.id)"
           >
             <view class="icon-chip icon-chip-red mr-3">
-              <text class="uni-icons uniui-notification-filled chip-glyph-red"></text>
+              <text class="uni-icons uniui-notification chip-glyph-red"></text>
             </view>
             <view class="flex-1 min-w-0 mr-2">
               <text class="row-title truncate">{{ task.projectName || '应急任务' }}</text>

+ 1 - 1
src/subPackages/pages-dispatch/notification.vue

@@ -22,7 +22,7 @@
           @click="goToDetail(notice.noticeId)"
         >
           <view class="icon-chip mr-3">
-            <text class="uni-icons uniui-notification-filled chip-glyph"></text>
+            <text class="uni-icons uniui-notification chip-glyph"></text>
             <view v-if="!notice.read" class="unread-dot"></view>
           </view>
           <view class="flex-1 min-w-0 mr-2">

+ 167 - 592
src/subPackages/pages-dispatch/publishTask.vue

@@ -15,25 +15,6 @@
           <text class="hint-text">请从项目库选择或新增项目</text>
         </view>
 
-        <view class="form-group">
-          <text class="form-label">
-            <text class="text-red-500">*</text>任务名称
-          </text>
-          <uni-easyinput class="form-input" v-model="taskName" placeholder="如:3号楼化粪池清掏" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">
-            <text class="text-red-500">*</text>任务类型
-          </text>
-          <picker mode="selector" :range="taskTypeLabels" :value="taskTypeIndex" @change="onTaskTypeChange">
-            <view class="form-select flex justify-between items-center">
-              <text :class="taskType ? 'text-gray-800' : 'text-gray-400'">{{ taskTypeLabel || '请选择任务类型' }}</text>
-              <text class="uni-icons uniui-arrowdown text-gray-400"></text>
-            </view>
-          </picker>
-        </view>
-
         <view class="form-group">
           <text class="form-label">
             <text class="text-red-500">*</text>选择项目
@@ -54,27 +35,63 @@
           <view class="gap-y-2">
             <view class="flex justify-between text-sm">
               <text class="text-gray-500">合作类型:</text>
-              <text class="text-gray-700">{{ getCooperationTypeText(selectedProject.cooperationType) }}</text>
+              <text class="text-gray-700">{{ form.cooperationType || '-' }}</text>
             </view>
             <view class="flex justify-between text-sm">
               <text class="text-gray-500">设施类型:</text>
-              <text class="text-gray-700 flex-1 text-right">{{ selectedFacilityText }}</text>
-            </view>
-            <view class="flex justify-between text-sm">
-              <text class="text-gray-500">项目地址:</text>
-              <text class="text-gray-700 flex-1 text-right">{{ selectedAddressText }}</text>
+              <text class="text-gray-700 flex-1 text-right">{{ form.facilityType || '-' }}</text>
             </view>
           </view>
         </view>
 
+        <view class="form-group">
+          <text class="form-label">选择服务点</text>
+          <picker mode="selector" :range="servicePointNames" :value="servicePointIndex" @change="onServicePointChange" :disabled="servicePointNames.length === 0">
+            <view class="form-select flex justify-between items-center">
+              <text :class="form.servicePointName ? 'text-gray-800' : 'text-gray-400'">{{ selectedServicePointText }}</text>
+              <text class="uni-icons uniui-arrowdown text-gray-400"></text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">
+            <text class="text-red-500">*</text>项目地址
+          </text>
+          <uni-easyinput class="form-input" v-model="form.address" placeholder="选择项目或服务点后自动带出" />
+        </view>
+
         <view class="form-group">
           <text class="form-label">主联系人姓名</text>
-          <uni-easyinput class="form-input" v-model="contactName" placeholder="自动带出,可修改" />
+          <uni-easyinput class="form-input" v-model="form.contactName" placeholder="自动带出,可修改" />
         </view>
 
         <view class="form-group mb-0">
           <text class="form-label">主联系人电话</text>
-          <uni-easyinput class="form-input" v-model="contactPhone" placeholder="自动带出,可修改" />
+          <uni-easyinput class="form-input" v-model="form.contactPhone" placeholder="自动带出,可修改" />
+        </view>
+
+        <view class="form-group">
+          <text class="form-label">备用联系人</text>
+          <view v-if="form.contacts.length > 0" class="gap-y-2 mb-2">
+            <view
+              v-for="(contact, index) in form.contacts"
+              :key="index"
+              class="contact-row"
+            >
+              <view class="flex-1">
+                <input v-model="contact.name" class="contact-input" placeholder="姓名" />
+              </view>
+              <view class="flex-1 mx-2">
+                <input v-model="contact.phone" class="contact-input" placeholder="电话" />
+              </view>
+              <text class="uni-icons uniui-close text-gray-400 ml-2" @click="removeContact(index)"></text>
+            </view>
+          </view>
+          <view class="add-contact-btn" @click="addContact">
+            <text class="uni-icons uniui-plus text-green-500 mr-1"></text>
+            <text class="text-green-500 text-sm">添加备用联系人</text>
+          </view>
         </view>
       </view>
 
@@ -85,11 +102,31 @@
           <text class="section-title">服务信息</text>
         </view>
 
+        <view class="form-group">
+          <text class="form-label">
+            <text class="text-red-500">*</text>服务时间
+          </text>
+          <view class="flex gap-2">
+            <picker mode="date" :value="serviceTimeDate" @change="onServiceTimeDateChange" class="flex-1">
+              <view class="form-select flex justify-between items-center">
+                <text :class="serviceTimeDate ? 'text-gray-800' : 'text-gray-400'">{{ serviceTimeDate || '选择日期' }}</text>
+                <text class="uni-icons uniui-calendar text-gray-400"></text>
+              </view>
+            </picker>
+            <picker mode="time" :value="serviceTimeTime" @change="onServiceTimeTimeChange" style="flex: 0.6;">
+              <view class="form-select flex justify-between items-center">
+                <text :class="serviceTimeTime ? 'text-gray-800' : 'text-gray-400'">{{ serviceTimeTime || '选择时间' }}</text>
+                <text class="uni-icons uniui-calendar-filled text-gray-400"></text>
+              </view>
+            </picker>
+          </view>
+        </view>
+
         <view class="form-group">
           <text class="form-label">
             <text class="text-red-500">*</text>故障位置
           </text>
-          <uni-easyinput class="form-input" v-model="faultLocation" placeholder="如:3号楼东侧化粪池" />
+          <uni-easyinput class="form-input" v-model="form.faultLocation" placeholder="如:3号楼东侧化粪池" />
         </view>
 
         <view class="form-group">
@@ -97,7 +134,7 @@
           <view class="flex items-center gap-2">
             <uni-easyinput
               class="form-input flex-1"
-              v-model="geoLocation"
+              v-model="form.geoLocation"
               placeholder="请输入地理位置或点击地图选点"
             />
             <view class="map-btn" @click="chooseLocation">
@@ -105,286 +142,119 @@
               <text class="map-btn-text">地图</text>
             </view>
           </view>
-          <text v-if="latitude && longitude" class="text-xs text-gray-400 mt-1">
-            {{ latitude }}, {{ longitude }}
+          <text v-if="form.latitude && form.longitude" class="text-xs text-gray-400 mt-1">
+            {{ form.latitude }}, {{ form.longitude }}
           </text>
         </view>
 
         <view class="form-group">
           <text class="form-label">
-            <text class="text-red-500">*</text>故障类型
+            <text class="text-red-500">*</text>故障描述
           </text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="type in faultTypes"
-              :key="type"
-              class="fault-chip"
-              :class="{ 'fault-chip-on': selectedFaultTypes.includes(type) }"
-              @click="toggleFaultType(type)"
-            >
-              {{ type }}
-            </view>
-          </view>
+          <textarea class="form-textarea" v-model="form.faultDescription" placeholder="请详细描述故障情况" />
         </view>
 
         <view class="form-group">
-          <text class="form-label">客户原话</text>
-          <textarea class="form-textarea" v-model="customerWords" placeholder="记录客户的原始描述" />
+          <text class="form-label">故障图片/视频</text>
+          <PhotoUploader v-model="form.faultPhotos" :max-count="9" />
         </view>
 
         <view class="form-group">
           <text class="form-label">
-            <text class="text-red-500">*</text>故障描述
+            <text class="text-red-500">*</text>施工类型
           </text>
-          <textarea class="form-textarea" v-model="faultDescription" placeholder="详细描述故障情况" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">首次出现时间</text>
-          <view class="flex gap-2">
-            <picker mode="date" :value="firstOccurrenceDate" @change="onFirstOccurrenceDateChange" class="flex-1">
-              <view class="form-select flex justify-between items-center">
-                <text :class="firstOccurrenceDate ? 'text-gray-800' : 'text-gray-400'">{{ firstOccurrenceDate || '选择日期' }}</text>
-                <text class="uni-icons uniui-calendar text-gray-400"></text>
-              </view>
-            </picker>
-            <picker mode="time" :value="firstOccurrenceTime" @change="onFirstOccurrenceTimeChange" style="flex: 0.6;">
-              <view class="form-select flex justify-between items-center">
-                <text :class="firstOccurrenceTime ? 'text-gray-800' : 'text-gray-400'">{{ firstOccurrenceTime || '选择时间' }}</text>
-                <text class="uni-icons uniui-clock text-gray-400"></text>
-              </view>
-            </picker>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">近期反复情况</text>
-          <picker mode="selector" :range="recurrenceOptions" :value="recurrenceIndex" @change="onRecurrenceChange">
+          <picker mode="selector" :range="constructionTypeLabels" :value="constructionTypeIndex" @change="onConstructionTypeChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="recurrenceStatus ? 'text-gray-800' : 'text-gray-400'">{{ recurrenceStatus || '请选择' }}</text>
+              <text :class="form.constructionType ? 'text-gray-800' : 'text-gray-400'">{{ form.constructionType || '请选择施工类型' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
 
         <view class="form-group">
-          <text class="form-label">影响范围</text>
-          <picker mode="selector" :range="impactScopeOptions" :value="impactScopeIndex" @change="onImpactScopeChange">
-            <view class="form-select flex justify-between items-center">
-              <text :class="impactScope ? 'text-gray-800' : 'text-gray-400'">{{ impactScope || '请选择影响范围' }}</text>
-              <text class="uni-icons uniui-arrowdown text-gray-400"></text>
-            </view>
-          </picker>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">故障图片/视频</text>
-          <PhotoUploader v-model="faultPhotos" :max-count="9" />
-        </view>
-
-        <view class="form-group mb-0">
-          <text class="form-label">施工类型</text>
-          <picker mode="selector" :range="constructionTypes" :value="constructionTypeIndex" @change="onConstructionTypeChange">
+          <text class="form-label">
+            <text class="text-red-500">*</text>施工设施
+          </text>
+          <picker mode="selector" :range="constructionFacilityLabels" :value="constructionFacilityIndex" @change="onConstructionFacilityChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="constructionType ? 'text-gray-800' : 'text-gray-400'">{{ constructionType || '请选择施工类型' }}</text>
+              <text :class="form.constructionFacility ? 'text-gray-800' : 'text-gray-400'">{{ form.constructionFacility || '请选择施工设施' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
-      </view>
-
-      <!-- 服务与结算数据 -->
-      <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title">服务与结算数据</text>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">期望到达时间</text>
-          <view class="flex gap-2">
-            <picker mode="date" :value="expectedArrivalDate" @change="onExpectedArrivalDateChange" class="flex-1">
-              <view class="form-select flex justify-between items-center">
-                <text :class="expectedArrivalDate ? 'text-gray-800' : 'text-gray-400'">{{ expectedArrivalDate || '选择日期' }}</text>
-                <text class="uni-icons uniui-calendar text-gray-400"></text>
-              </view>
-            </picker>
-            <picker mode="time" :value="expectedArrivalTime" @change="onExpectedArrivalTimeChange" style="flex: 0.6;">
-              <view class="form-select flex justify-between items-center">
-                <text :class="expectedArrivalTime ? 'text-gray-800' : 'text-gray-400'">{{ expectedArrivalTime || '选择时间' }}</text>
-                <text class="uni-icons uniui-clock text-gray-400"></text>
-              </view>
-            </picker>
-          </view>
-        </view>
 
         <view class="form-group">
-          <text class="form-label">最晚处理时限</text>
-          <view class="flex gap-2">
-            <picker mode="date" :value="latestDeadlineDate" @change="onLatestDeadlineDateChange" class="flex-1">
-              <view class="form-select flex justify-between items-center">
-                <text :class="latestDeadlineDate ? 'text-gray-800' : 'text-gray-400'">{{ latestDeadlineDate || '选择日期' }}</text>
-                <text class="uni-icons uniui-calendar text-gray-400"></text>
-              </view>
-            </picker>
-            <picker mode="time" :value="latestDeadlineTime" @change="onLatestDeadlineTimeChange" style="flex: 0.6;">
-              <view class="form-select flex justify-between items-center">
-                <text :class="latestDeadlineTime ? 'text-gray-800' : 'text-gray-400'">{{ latestDeadlineTime || '选择时间' }}</text>
-                <text class="uni-icons uniui-clock text-gray-400"></text>
-              </view>
-            </picker>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">验收标准</text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="item in acceptanceStandardOptions"
-              :key="item"
-              class="radio-chip"
-              :class="{ 'radio-chip-on': acceptanceStandard === item }"
-              @click="acceptanceStandard = item"
-            >
-              {{ item }}
-            </view>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">结算方式</text>
-          <picker mode="selector" :range="settlementMethodOptions" :value="settlementMethodIndex" @change="onSettlementMethodChange">
+          <text class="form-label">
+            <text class="text-red-500">*</text>服务方式
+          </text>
+          <picker mode="selector" :range="serviceMethodLabels" :value="serviceMethodIndex" @change="onServiceMethodChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="settlementMethod ? 'text-gray-800' : 'text-gray-400'">{{ settlementMethod || '请选择结算方式' }}</text>
+              <text :class="form.serviceMethod ? 'text-gray-800' : 'text-gray-400'">{{ form.serviceMethod || '请选择服务方式' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
 
         <view class="form-group">
-          <text class="form-label">堵点个数</text>
-          <uni-easyinput class="form-input" v-model="blockageCount" type="digit" placeholder="请输入" />
-        </view>
-
-        <view class="form-group mb-0">
-          <text class="form-label">清淤方量(m³)</text>
-          <uni-easyinput class="form-input" v-model="dredgingVolume" type="digit" placeholder="请输入" />
-        </view>
-      </view>
-
-      <!-- 工单生成数据 -->
-      <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title">工单生成数据</text>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">施工指令</text>
-          <textarea class="form-textarea" v-model="constructionInstruction" placeholder="根据故障类型自动生成" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">风险提示</text>
-          <textarea class="form-textarea" v-model="riskTip" placeholder="根据故障类型自动生成" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">派单结果判定</text>
+          <text class="form-label">
+            <text class="text-red-500">*</text>建议出车
+          </text>
           <view class="flex flex-wrap gap-2">
             <view
-              v-for="item in dispatchResultOptions"
-              :key="item"
-              class="radio-chip"
-              :class="{ 'radio-chip-on': dispatchResult === item }"
-              @click="dispatchResult = item"
+              v-for="item in suggestedVehicleOptions"
+              :key="item.value"
+              class="fault-chip"
+              :class="{ 'fault-chip-on': form.suggestedVehicles.includes(item.value) }"
+              @click="toggleVehicle(item.value)"
             >
-              {{ item }}
+              {{ item.label }}
             </view>
           </view>
         </view>
 
         <view class="form-group">
-          <text class="form-label">施工设施</text>
-          <picker mode="selector" :range="constructionFacilityOptions" :value="constructionFacilityIndex" @change="onConstructionFacilityChange">
+          <text class="form-label">取水距离</text>
+          <picker mode="selector" :range="waterDistanceLabels" :value="waterDistanceIndex" @change="onWaterDistanceChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="constructionFacility ? 'text-gray-800' : 'text-gray-400'">{{ constructionFacility || '请选择施工设施' }}</text>
+              <text :class="form.waterDistance ? 'text-gray-800' : 'text-gray-400'">{{ form.waterDistance || '请选择取水距离' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
         </view>
 
         <view class="form-group">
-          <text class="form-label">服务方式</text>
-          <uni-easyinput class="form-input" v-model="serviceMethod" placeholder="如:月计划" />
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">建议出车</text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="v in vehicleOptions"
-              :key="v"
-              class="fault-chip"
-              :class="{ 'fault-chip-on': suggestedVehicles.includes(v) }"
-              @click="toggleVehicle(v)"
-            >
-              {{ v }}
-            </view>
-          </view>
-        </view>
-
-        <view class="form-group">
-          <text class="form-label">取水距离</text>
-          <view class="flex flex-wrap gap-2">
-            <view
-              v-for="item in waterOptions"
-              :key="item"
-              class="radio-chip"
-              :class="{ 'radio-chip-on': waterDistance === item }"
-              @click="waterDistance = item"
-            >
-              {{ item }}
-            </view>
-          </view>
-        </view>
-
-        <view class="form-group mb-0">
           <text class="form-label">化粪池排口是否正常</text>
           <view class="flex flex-wrap gap-2">
             <view
               v-for="item in yesNoOptions"
               :key="item.value"
               class="radio-chip"
-              :class="{ 'radio-chip-on': outletNormal === item.value }"
-              @click="outletNormal = item.value"
+              :class="{ 'radio-chip-on': form.outletNormal === item.value }"
+              @click="setOutletNormal(item.value)"
             >
               {{ item.label }}
             </view>
           </view>
         </view>
-      </view>
 
-      <!-- 施工助力信息 -->
-      <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
-          <view class="section-bar mr-2"></view>
-          <text class="section-title">施工助力信息</text>
+        <view class="form-group">
+          <text class="form-label">施工助力信息</text>
+          <textarea class="form-textarea" v-model="form.constructionHelp" placeholder="如地库限高、需搭脚手架、登高作业等" />
         </view>
 
         <view class="form-group">
           <text class="form-label">预计作业量</text>
-          <uni-easyinput class="form-input" v-model="estimatedWorkload" placeholder="如:2车" />
+          <uni-easyinput class="form-input" v-model="form.estimatedQuantity" placeholder="如:2个堵点" />
         </view>
 
         <view class="form-group">
           <text class="form-label">预计工时</text>
-          <uni-easyinput class="form-input" v-model="estimatedDuration" placeholder="如:4小时" />
+          <uni-easyinput class="form-input" v-model="form.estimatedHours" placeholder="如:2小时" />
         </view>
 
         <view class="form-group">
           <text class="form-label">验收要求</text>
-          <uni-easyinput class="form-input" v-model="acceptanceRequirement" placeholder="验收的具体要求" />
+          <uni-easyinput class="form-input" v-model="form.acceptanceReq" placeholder="如:正常走水" />
         </view>
 
         <view class="form-group">
@@ -394,8 +264,8 @@
               v-for="item in yesNoOptions"
               :key="item.value"
               class="radio-chip"
-              :class="{ 'radio-chip-on': hasPreviousWork === item.value }"
-              @click="hasPreviousWork = item.value"
+              :class="{ 'radio-chip-on': form.hasPreviousWork === item.value }"
+              @click="setHasPreviousWork(item.value)"
             >
               {{ item.label }}
             </view>
@@ -403,10 +273,12 @@
         </view>
 
         <view class="form-group">
-          <text class="form-label">验收方式</text>
-          <picker mode="selector" :range="acceptTypes" :value="acceptanceMethodIndex" @change="onAcceptanceMethodChange">
+          <text class="form-label">
+            <text class="text-red-500">*</text>验收方式
+          </text>
+          <picker mode="selector" :range="acceptanceMethods" :value="acceptanceMethodIndex" @change="onAcceptanceMethodChange">
             <view class="form-select flex justify-between items-center">
-              <text :class="acceptanceMethod ? 'text-gray-800' : 'text-gray-400'">{{ acceptanceMethod || '请选择验收方式' }}</text>
+              <text :class="form.acceptanceMethod ? 'text-gray-800' : 'text-gray-400'">{{ form.acceptanceMethod || '请选择验收方式' }}</text>
               <text class="uni-icons uniui-arrowdown text-gray-400"></text>
             </view>
           </picker>
@@ -414,9 +286,9 @@
 
         <view class="form-group">
           <text class="form-label">甲方当事人</text>
-          <view v-if="partyAContacts.length > 0" class="gap-y-2 mb-2">
+          <view v-if="form.partyAContacts.length > 0" class="gap-y-2 mb-2">
             <view
-              v-for="(contact, index) in partyAContacts"
+              v-for="(contact, index) in form.partyAContacts"
               :key="index"
               class="contact-row"
             >
@@ -440,7 +312,7 @@
 
         <view class="form-group mb-0">
           <text class="form-label">备注</text>
-          <textarea class="form-textarea" v-model="remark" placeholder="其他需要说明的事项" />
+          <textarea class="form-textarea" v-model="form.remark" placeholder="如有其他要求请备注" />
         </view>
       </view>
 
@@ -451,9 +323,10 @@
           hover-class="submit-btn-hover"
           :hover-start-time="0"
           :hover-stay-time="120"
+          :disabled="submitting"
           @click="submitTask"
         >
-          发布任务
+          {{ submitting ? '发布中...' : '发布任务' }}
         </button>
       </view>
     </view>
@@ -461,361 +334,59 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
-import { onLoad } from '@dcloudio/uni-app'
 import TopBar from '@/components/common/TopBar.vue'
 import PhotoUploader from '@/components/common/PhotoUploader.vue'
-import { useProjectStore } from '@/stores/project'
-import { createTask } from '@/api/task'
-import {
-  showToast,
-  navigateTo,
-  getCooperationTypeText,
-  getFacilityTypeText,
-  parseJsonArray
-} from '@/utils'
-import type { FacilityType } from '@/types'
-
-const projectStore = useProjectStore()
-
-const projectNames = computed(() => [...projectStore.projects.map(p => p.projectName), '+ 新增项目'])
-const projects = computed(() => projectStore.projects)
-
-const selectedProject = ref<any>(null)
-const projectIndex = computed(() => {
-  if (!selectedProject.value) return -1
-  return projects.value.findIndex(p => String(p.projectId) === String(selectedProject.value?.projectId))
-})
-
-// 项目基本信息
-const taskName = ref('')
-const taskType = ref('')
-const contactName = ref('')
-const contactPhone = ref('')
-
-// 服务信息
-const faultLocation = ref('')
-const geoLocation = ref('')
-const faultTypes = ['堵塞', '满溢', '排水慢', '异味', '其他']
-const selectedFaultTypes = ref<string[]>([])
-const customerWords = ref('')
-const faultDescription = ref('')
-const firstOccurrenceDate = ref('')
-const firstOccurrenceTime = ref('')
-const recurrenceOptions = ['偶尔', '频繁']
-const recurrenceStatus = ref('')
-const impactScopeOptions = ['单户', '楼栋', '小区主干道', '商铺门口']
-const impactScope = ref('')
-const constructionTypes = ['清淤', '排查']
-const constructionType = ref('')
-const specialRequirement = ref('')
+import { usePublishTaskForm } from '@/composables/usePublishTaskForm'
 
-// 服务与结算数据
-const expectedArrivalDate = ref('')
-const expectedArrivalTime = ref('')
-const latestDeadlineDate = ref('')
-const latestDeadlineTime = ref('')
-const acceptanceStandardOptions = ['客户自定义', '公司标准']
-const acceptanceStandard = ref('')
-const settlementMethodOptions = ['签单', '现结', '对公转账', '二维码']
-const settlementMethod = ref('')
-const blockageCount = ref('')
-const dredgingVolume = ref('')
-
-// 工单生成数据
-const constructionInstruction = ref('')
-const riskTip = ref('')
-const dispatchResultOptions = ['派单成功', '暂缓派单', '拒绝派单']
-const dispatchResult = ref('')
-const constructionFacilityOptions = ['油污', '排污']
-const constructionFacility = ref('')
-const serviceMethod = ref('')
-const vehicleOptions = ['疏通车', '吸污车', '高压清洗车']
-const suggestedVehicles = ref<string[]>([])
-const waterOptions = ['有消防水', '无消防水']
-const waterDistance = ref('')
 const yesNoOptions = [
   { label: '是', value: 1 },
   { label: '否', value: 0 }
 ]
-const outletNormal = ref<number | undefined>(undefined)
-
-// 施工助力信息
-const estimatedWorkload = ref('')
-const estimatedDuration = ref('')
-const acceptanceRequirement = ref('')
-const hasPreviousWork = ref<number | undefined>(undefined)
-const acceptTypes = ['签署验收计量单', '累计自动计费']
-const acceptanceMethod = ref('')
-const partyAContacts = ref<Array<{ name: string; phone: string; position?: string }>>([])
-const remark = ref('')
-const faultPhotos = ref<string[]>([])
-
-const latitude = ref('')
-const longitude = ref('')
-
-// 任务类型选项
-const taskTypeOptions = [
-  { label: '疏通', value: 'dredge' },
-  { label: '维修', value: 'repair' },
-  { label: '清掏', value: 'clean' },
-  { label: '检查', value: 'inspect' },
-  { label: '应急', value: 'emergency' }
-]
-const taskTypeLabels = taskTypeOptions.map(t => t.label)
-const taskTypeIndex = computed(() => taskTypeOptions.findIndex(t => t.value === taskType.value))
-const taskTypeLabel = computed(() => taskTypeOptions.find(t => t.value === taskType.value)?.label || '')
-
-const selectedFacilityText = computed(() => {
-  const list = parseJsonArray<FacilityType>(selectedProject.value?.facilityTypes)
-  return list.length > 0 ? list.map(getFacilityTypeText).join('、') : '-'
-})
-
-const selectedAddressText = computed(() => {
-  const p = selectedProject.value
-  if (!p) return ''
-  if (p.address) return p.address
-  return [p.province, p.city, p.district, p.street, p.detailAddress].filter(Boolean).join('') || '-'
-})
-
-const recurrenceIndex = computed(() => recurrenceOptions.indexOf(recurrenceStatus.value))
-const impactScopeIndex = computed(() => impactScopeOptions.indexOf(impactScope.value))
-const constructionTypeIndex = computed(() => constructionTypes.indexOf(constructionType.value))
-const settlementMethodIndex = computed(() => settlementMethodOptions.indexOf(settlementMethod.value))
-const constructionFacilityIndex = computed(() => constructionFacilityOptions.indexOf(constructionFacility.value))
-const acceptanceMethodIndex = computed(() => acceptTypes.indexOf(acceptanceMethod.value))
-
-onLoad(async (query) => {
-  if (projectStore.projects.length === 0) {
-    try { await projectStore.fetchProjects(true) } catch (e) { /* ignore */ }
-  }
-  if (query?.projectId) {
-    const project = projectStore.projects.find(p => String(p.projectId) === String(query.projectId))
-    if (project) {
-      selectProject(project)
-    }
-  }
-})
-
-function selectProject(project: any) {
-  selectedProject.value = project
-  contactName.value = project.primaryContactName || ''
-  contactPhone.value = project.primaryContactPhone || ''
-}
-
-function onProjectSelectChange(e: any) {
-  const index = e.detail.value
-  if (index >= projects.value.length) {
-    navigateTo('/subPackages/pages-common/addProject')
-    return
-  }
-  selectProject(projects.value[index])
-}
-
-function resetProjectSelection() {
-  selectedProject.value = null
-  contactName.value = ''
-  contactPhone.value = ''
-}
 
-function onTaskTypeChange(e: any) {
-  taskType.value = taskTypeOptions[Number(e.detail.value)].value
-}
-
-function toggleFaultType(type: string) {
-  const idx = selectedFaultTypes.value.indexOf(type)
-  if (idx > -1) {
-    selectedFaultTypes.value.splice(idx, 1)
-  } else {
-    selectedFaultTypes.value.push(type)
-  }
-  updateAutoInstruction()
-}
-
-function toggleVehicle(v: string) {
-  const idx = suggestedVehicles.value.indexOf(v)
-  if (idx > -1) {
-    suggestedVehicles.value.splice(idx, 1)
-  } else {
-    suggestedVehicles.value.push(v)
-  }
-}
-
-function onFirstOccurrenceDateChange(e: any) { firstOccurrenceDate.value = e.detail.value }
-function onFirstOccurrenceTimeChange(e: any) { firstOccurrenceTime.value = e.detail.value }
-function onRecurrenceChange(e: any) { recurrenceStatus.value = recurrenceOptions[Number(e.detail.value)] }
-function onImpactScopeChange(e: any) { impactScope.value = impactScopeOptions[Number(e.detail.value)] }
-function onConstructionTypeChange(e: any) { constructionType.value = constructionTypes[Number(e.detail.value)] }
-function onExpectedArrivalDateChange(e: any) { expectedArrivalDate.value = e.detail.value }
-function onExpectedArrivalTimeChange(e: any) { expectedArrivalTime.value = e.detail.value }
-function onLatestDeadlineDateChange(e: any) { latestDeadlineDate.value = e.detail.value }
-function onLatestDeadlineTimeChange(e: any) { latestDeadlineTime.value = e.detail.value }
-function onSettlementMethodChange(e: any) { settlementMethod.value = settlementMethodOptions[Number(e.detail.value)] }
-function onConstructionFacilityChange(e: any) { constructionFacility.value = constructionFacilityOptions[Number(e.detail.value)] }
-function onAcceptanceMethodChange(e: any) { acceptanceMethod.value = acceptTypes[Number(e.detail.value)] }
-
-function chooseLocation() {
-  uni.authorize({
-    scope: 'scope.userLocation',
-    success: () => {
-      uni.chooseLocation({
-        success: (res) => {
-          geoLocation.value = res.address || res.name || ''
-          latitude.value = String(res.latitude ?? '')
-          longitude.value = String(res.longitude ?? '')
-        },
-        fail: (err) => {
-          console.error('chooseLocation fail', err)
-          uni.showToast({ title: '选择位置失败', icon: 'none' })
-        }
-      })
-    },
-    fail: () => {
-      uni.showModal({
-        title: '需要位置权限',
-        content: '请在设置中允许使用位置信息',
-        success: (res) => {
-          if (res.confirm) {
-            uni.openSetting()
-          }
-        }
-      })
-    }
-  })
-}
-
-function addPartyAContact() {
-  partyAContacts.value.push({ name: '', phone: '', position: '' })
-}
-
-function removePartyAContact(index: number) {
-  partyAContacts.value.splice(index, 1)
-}
-
-function updateAutoInstruction() {
-  let instruction = ''
-  let risk = ''
-
-  if (selectedFaultTypes.value.includes('堵塞')) {
-    instruction += '使用疏通设备清除堵塞物;'
-    risk += '注意检查堵塞物性质,避免损坏管道;'
-  }
-  if (selectedFaultTypes.value.includes('满溢')) {
-    instruction += '先抽排溢出的污水,再进行清理;'
-    risk += '满溢区域可能存在滑倒风险,请设置警示标识;'
-  }
-  if (selectedFaultTypes.value.includes('异味')) {
-    instruction += '检查是否有有害气体,必要时进行通风处理;'
-    risk += '可能存在硫化氢等有毒气体,必须佩戴防护装备;'
-  }
-
-  constructionInstruction.value = instruction || '根据现场情况制定施工方案'
-  riskTip.value = risk || '注意施工安全,佩戴必要的防护装备'
-}
-
-function combineDateTime(date: string, time: string): string {
-  if (!date) return ''
-  return time ? `${date}T${time}` : `${date}T00:00:00`
-}
-
-function isRemoteUrl(url: string): boolean {
-  return /^https?:\/\//.test(url)
-}
-
-function validateForm(): boolean {
-  if (!taskName.value.trim()) { showToast('请输入任务名称'); return false }
-  if (!taskType.value) { showToast('请选择任务类型'); return false }
-  if (!selectedProject.value) { showToast('请选择项目'); return false }
-  if (!faultLocation.value.trim()) { showToast('请输入故障位置'); return false }
-  if (selectedFaultTypes.value.length === 0) { showToast('请选择故障类型'); return false }
-  if (!faultDescription.value.trim()) { showToast('请输入故障描述'); return false }
-  return true
-}
-
-async function submitTask() {
-  if (!validateForm()) return
-
-  if (faultPhotos.value.some((u) => !isRemoteUrl(u))) {
-    showToast('图片正在上传,请稍候')
-    return
-  }
-
-  const p = selectedProject.value!
-  const address = p.address || [p.province, p.city, p.district, p.street, p.detailAddress].filter(Boolean).join('') || '-'
-  const expectedArrivalTimeStr = combineDateTime(expectedArrivalDate.value, expectedArrivalTime.value)
-  const latestDeadlineStr = combineDateTime(latestDeadlineDate.value, latestDeadlineTime.value)
-  const firstOccurrenceTimeStr = combineDateTime(firstOccurrenceDate.value, firstOccurrenceTime.value)
-
-  const payload = {
-    taskNo: `TK-${Date.now()}`,
-    taskName: taskName.value.trim(),
-    projectId: p.projectId,
-    taskType: taskType.value,
-    status: 'auditing',
-    planDate: expectedArrivalTimeStr ? expectedArrivalTimeStr.split('T')[0] : new Date().toISOString().split('T')[0],
-    address,
-    remark: remark.value,
-    facilityType: selectedFacilityText.value,
-    serviceContent: faultDescription.value,
-    serviceRequirement: constructionType.value,
-    specialRequirement: specialRequirement.value,
-    urgencyLevel: 'normal',
-    planTimeRange: 'morning',
-    faultLocation: faultLocation.value,
-    faultType: selectedFaultTypes.value.join(','),
-    customerWords: customerWords.value,
-    firstOccurrenceTime: firstOccurrenceTimeStr,
-    recurrenceStatus: recurrenceStatus.value,
-    impactScope: impactScope.value,
-    constructionType: constructionType.value,
-    settlementType: settlementMethod.value === '签单' ? 'contract' : 'extra',
-    settlementAmount: 0,
-    settlementMethod: settlementMethod.value,
-    expectedArrivalTime: expectedArrivalTimeStr,
-    latestDeadline: latestDeadlineStr,
-    acceptanceStandard: acceptanceStandard.value,
-    blockageCount: blockageCount.value ? Number(blockageCount.value) : undefined,
-    dredgingVolume: dredgingVolume.value ? Number(dredgingVolume.value) : undefined,
-    workOrderNo: `GD-${Date.now()}`,
-    constructionInstruction: constructionInstruction.value,
-    riskTip: riskTip.value,
-    dispatchResult: dispatchResult.value,
-    constructionFacility: constructionFacility.value,
-    serviceMethod: serviceMethod.value,
-    waterDistance: waterDistance.value,
-    outletNormal: outletNormal.value,
-    assistantInfo: estimatedWorkload.value,
-    attentionPoint: acceptanceRequirement.value,
-    toolRequirement: suggestedVehicles.value.join(','),
-    estimatedWorkload: estimatedWorkload.value,
-    estimatedDuration: estimatedDuration.value,
-    acceptanceRequirement: acceptanceRequirement.value,
-    hasPreviousWork: hasPreviousWork.value,
-    acceptanceMethod: acceptanceMethod.value,
-    extraData: JSON.stringify({
-      cooperationType: p.cooperationType,
-      contactName: contactName.value,
-      contactPhone: contactPhone.value,
-      partyAContacts: partyAContacts.value,
-      geoLocation: geoLocation.value,
-      latitude: latitude.value,
-      longitude: longitude.value,
-      faultPhotos: faultPhotos.value,
-      projectName: p.projectName,
-      customerName: p.customerName,
-      customerPhone: p.customerPhone,
-      suggestedVehicles: suggestedVehicles.value,
-    })
-  }
-
-  try {
-    await createTask(payload)
-    showToast({ title: '任务发布成功', icon: 'success' })
-    setTimeout(() => uni.navigateBack(), 1500)
-  } catch (e) {
-    // request.ts 已统一提示错误信息
-  }
-}
+const {
+  form,
+  submitting,
+  projectNames,
+  projectIndex,
+  selectedProject,
+  servicePointNames,
+  servicePointIndex,
+  selectedServicePointText,
+  constructionTypeLabels,
+  constructionTypeIndex,
+  constructionFacilityLabels,
+  constructionFacilityIndex,
+  serviceMethodLabels,
+  serviceMethodIndex,
+  suggestedVehicleLabels,
+  suggestedVehicleOptions,
+  waterDistanceLabels,
+  waterDistanceIndex,
+  acceptanceMethods,
+  acceptanceMethodIndex,
+  serviceTimeDate,
+  serviceTimeTime,
+
+  onProjectSelectChange,
+  onServicePointChange,
+  resetProjectSelection,
+  onConstructionTypeChange,
+  onConstructionFacilityChange,
+  onServiceMethodChange,
+  onWaterDistanceChange,
+  onAcceptanceMethodChange,
+  toggleVehicle,
+  chooseLocation,
+  addContact,
+  removeContact,
+  addPartyAContact,
+  removePartyAContact,
+  onServiceTimeDateChange,
+  onServiceTimeTimeChange,
+  setOutletNormal,
+  setHasPreviousWork,
+  submitTask
+} = usePublishTaskForm()
 </script>
 
 <style scoped>
@@ -884,7 +455,7 @@ async function submitTask() {
   border: 1px solid rgba(164, 216, 152, 0.3);
 }
 
-/* 故障类型/选项标签:默认白底灰边,激活态绿色 */
+/* 选项标签:默认白底灰边,激活态绿色 */
 .fault-chip {
   padding: 5px 14px;
   border-radius: 999px;
@@ -994,6 +565,10 @@ async function submitTask() {
   opacity: 0.88;
 }
 
+.submit-btn[disabled] {
+  opacity: 0.6;
+}
+
 .map-btn {
   display: flex;
   flex-direction: column;

+ 200 - 59
src/subPackages/pages-dispatch/taskDetail.vue

@@ -13,42 +13,42 @@
           <view class="section-bar mr-2"></view>
           <text class="section-title flex-1">基本信息</text>
           <StatusTag :status="task.status" />
-          <view v-if="canUrge" class="urge-btn ml-2" @click="handleUrge">
-            <text class="uni-icons uniui-notification-filled urge-icon mr-1"></text>
+          <view v-if="canUrge" class="urge-btn ml-2" hover-class="urge-btn-hover" :hover-start-time="0" :hover-stay-time="120" @click="handleUrge">
+            <text class="uni-icons uniui-notification urge-icon mr-1"></text>
             <text class="urge-text">催单</text>
           </view>
         </view>
         <view class="gap-y-3">
           <view class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">任务名称</text>
+            <text class="uni-icons uniui-flag-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">任务名称</text>
             <text class="text-sm text-gray-800 flex-1">{{ taskName }}</text>
           </view>
           <view v-if="task.taskNo" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">任务编号</text>
+            <text class="uni-icons uniui-list field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">任务编号</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.taskNo }}</text>
           </view>
           <view v-if="task.faultLocation" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">故障位置</text>
+            <text class="uni-icons uniui-location-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">故障位置</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.faultLocation }}</text>
           </view>
           <view class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">施工类型</text>
+            <text class="uni-icons uniui-gear-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">施工类型</text>
             <text class="text-sm text-gray-800 flex-1">{{ constructionTypeText }}</text>
           </view>
           <view class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">服务时间</text>
+            <text class="uni-icons uniui-calendar-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">服务时间</text>
             <text class="text-sm text-gray-800 flex-1">{{ serviceTimeText }}</text>
           </view>
           <view v-if="task.createBy" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">申请人</text>
+            <text class="uni-icons uniui-person-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">申请人</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.createBy }}</text>
           </view>
           <view v-if="task.createTime" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">申请时间</text>
+            <text class="uni-icons uniui-calendar-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">申请时间</text>
             <text class="text-sm text-gray-800 flex-1">{{ formatDate(task.createTime, 'YYYY-MM-DD HH:mm') }}</text>
           </view>
           <view v-if="urgencyText" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">紧急程度</text>
+            <text class="uni-icons uniui-flag-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">紧急程度</text>
             <text class="text-sm flex-1" :class="task.urgencyLevel === 'very_urgent' ? 'text-red-500' : 'text-gray-800'">{{ urgencyText }}</text>
           </view>
         </view>
@@ -62,24 +62,25 @@
         </view>
         <view class="gap-y-3">
           <view class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">项目名称</text>
+            <text class="uni-icons uniui-home-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">项目名称</text>
             <text class="text-sm text-gray-800 flex-1">{{ project.projectName }}</text>
           </view>
           <view v-if="project.cooperationType" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">合作类型</text>
+            <text class="uni-icons uniui-info-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">合作类型</text>
             <text class="text-sm text-gray-800 flex-1">{{ coopTypeText }}</text>
           </view>
           <view v-if="projectAddress" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">项目地址</text>
+            <text class="uni-icons uniui-location-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">项目地址</text>
             <text class="text-sm text-gray-800 flex-1">{{ projectAddress }}</text>
           </view>
           <view v-if="project.primaryContactName" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">联系人</text>
+            <text class="uni-icons uniui-person-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">联系人</text>
             <view class="flex-1 flex items-center justify-between">
               <text class="text-sm text-gray-800">{{ project.primaryContactName }} {{ project.primaryContactPhone || '' }}</text>
               <button
                 v-if="project.primaryContactPhone"
                 class="phone-circle"
+                hover-class="phone-btn-hover" :hover-start-time="0" :hover-stay-time="120"
                 @click="callPhone(project.primaryContactPhone)"
               >
                 <text class="uni-icons uniui-phone-filled phone-icon"></text>
@@ -97,11 +98,11 @@
         </view>
         <view class="gap-y-3">
           <view v-if="geoLocationText" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">地理位置</text>
+            <text class="uni-icons uniui-location-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">地理位置</text>
             <text class="text-sm link-text flex-1" @click="openMap">{{ geoLocationText }}(点击查看地图)</text>
           </view>
           <view v-if="faultMedia.length > 0" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">故障图片</text>
+            <text class="uni-icons uniui-image-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">故障图片</text>
             <view class="flex-1 flex flex-wrap gap-2">
               <image
                 v-for="(url, index) in faultMedia"
@@ -124,51 +125,51 @@
         </view>
         <view class="gap-y-3">
           <view v-if="faultDescText" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">故障描述</text>
+            <text class="uni-icons uniui-info-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">故障描述</text>
             <text class="text-sm text-gray-800 flex-1">{{ faultDescText }}</text>
           </view>
           <view v-if="task.constructionFacility" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">施工设施</text>
-            <text class="text-sm text-gray-800 flex-1">{{ task.constructionFacility }}</text>
+            <text class="uni-icons uniui-gear-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">施工设施</text>
+            <text class="text-sm text-gray-800 flex-1">{{ getFacilityTypeText(task.constructionFacility) }}</text>
           </view>
           <view v-if="task.serviceMethod" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">服务方式</text>
-            <text class="text-sm text-gray-800 flex-1">{{ task.serviceMethod }}</text>
+            <text class="uni-icons uniui-settings-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">服务方式</text>
+            <text class="text-sm text-gray-800 flex-1">{{ getServiceMethodText(task.serviceMethod) }}</text>
           </view>
           <view v-if="extra.suggestedVehicle" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">建议出车</text>
-            <text class="text-sm text-gray-800 flex-1">{{ extra.suggestedVehicle }}</text>
+            <text class="uni-icons uniui-gear-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">建议出车</text>
+            <text class="text-sm text-gray-800 flex-1">{{ getSuggestedVehicleText(extra.suggestedVehicle) }}</text>
           </view>
           <view v-if="task.waterDistance" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">取水距离</text>
-            <text class="text-sm text-gray-800 flex-1">{{ task.waterDistance }}</text>
+            <text class="uni-icons uniui-map-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">取水距离</text>
+            <text class="text-sm text-gray-800 flex-1">{{ getWaterDistanceText(task.waterDistance) }}</text>
           </view>
           <view v-if="task.outletNormal !== undefined && task.outletNormal !== null" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">化粪池排口</text>
+            <text class="uni-icons uniui-info-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">化粪池排口</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.outletNormal === 1 ? '正常' : '异常' }}</text>
           </view>
           <view v-if="task.assistantInfo" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">施工助力</text>
+            <text class="uni-icons uniui-person-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">施工助力</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.assistantInfo }}</text>
           </view>
           <view v-if="task.estimatedWorkload" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">预计作业量</text>
+            <text class="uni-icons uniui-info-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">预计作业量</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.estimatedWorkload }}</text>
           </view>
           <view v-if="task.estimatedDuration" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">预计工时</text>
+            <text class="uni-icons uniui-calendar-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">预计工时</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.estimatedDuration }}</text>
           </view>
           <view v-if="task.acceptanceRequirement" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">验收要求</text>
+            <text class="uni-icons uniui-checkmarkempty field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">验收要求</text>
             <text class="text-sm text-gray-800 flex-1">{{ task.acceptanceRequirement }}</text>
           </view>
           <view v-if="task.acceptanceMethod" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">验收方式</text>
-            <text class="text-sm text-gray-800 flex-1">{{ task.acceptanceMethod }}</text>
+            <text class="uni-icons uniui-checkmarkempty field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">验收方式</text>
+            <text class="text-sm text-gray-800 flex-1">{{ getAcceptanceMethodText(task.acceptanceMethod) }}</text>
           </view>
           <view v-if="partyAContacts.length > 0" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">甲方当事人</text>
+            <text class="uni-icons uniui-person-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">甲方当事人</text>
             <view class="flex-1">
               <view
                 v-for="(contact, index) in partyAContacts"
@@ -179,6 +180,7 @@
                 <button
                   v-if="contact.phone"
                   class="phone-circle"
+                  hover-class="phone-btn-hover" :hover-start-time="0" :hover-stay-time="120"
                   @click="callPhone(contact.phone)"
                 >
                   <text class="uni-icons uniui-phone-filled phone-icon"></text>
@@ -187,14 +189,14 @@
             </view>
           </view>
           <view v-if="task.remark" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">备注</text>
-            <text class="text-sm text-red-500 flex-1">{{ task.remark }}</text>
+            <text class="uni-icons uniui-info-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">备注</text>
+            <view class="remark-value flex-1"><text class="text-sm">{{ task.remark }}</text></view>
           </view>
         </view>
       </view>
 
       <!-- 审核操作卡片(待审核状态显示) -->
-      <view v-if="isPendingAudit" class="glass-card p-4">
+      <view v-if="isPendingAudit && !isMonthPlanTask" class="glass-card p-4">
         <view class="section-head-inner mb-3">
           <view class="section-bar mr-2"></view>
           <text class="section-title">审核操作</text>
@@ -236,7 +238,7 @@
       </view>
 
       <!-- 待安排操作卡片(approved 状态显示) -->
-      <view v-if="task.status === 'approved'" class="glass-card p-4">
+      <view v-if="task.status === 'approved' && !isMonthPlanTask" class="glass-card p-4">
         <view class="section-head-inner mb-3">
           <view class="section-bar mr-2"></view>
           <text class="section-title">安排班组</text>
@@ -284,19 +286,19 @@
         </view>
         <view class="gap-y-3">
           <view class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">执行班组</text>
+            <text class="uni-icons uniui-staff-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">执行班组</text>
             <text class="text-sm text-gray-800 flex-1">{{ dispatchedTeamName }}</text>
           </view>
           <view class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">调度车辆</text>
+            <text class="uni-icons uniui-gear-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">调度车辆</text>
             <text class="text-sm text-gray-800 flex-1">{{ dispatchedVehicleName }}</text>
           </view>
           <view v-if="auditOperator" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">审核人</text>
+            <text class="uni-icons uniui-person-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">审核人</text>
             <text class="text-sm text-gray-800 flex-1">{{ auditOperator }}</text>
           </view>
           <view v-if="task.auditTime" class="flex items-start">
-            <text class="text-gray-500 text-sm w-24">审核时间</text>
+            <text class="uni-icons uniui-calendar-filled field-icon mr-2"></text><text class="text-gray-500 text-sm w-24">审核时间</text>
             <text class="text-sm text-gray-800 flex-1">{{ formatDate(task.auditTime, 'YYYY-MM-DD HH:mm') }}</text>
           </view>
         </view>
@@ -338,11 +340,22 @@ import TopBar from '../../components/common/TopBar.vue'
 import StatusTag from '../../components/common/StatusTag.vue'
 import EmptyState from '../../components/common/EmptyState.vue'
 import { useAuthStore } from '../../stores/auth'
-import { formatDate, showToast } from '../../utils'
+import {
+  formatDate,
+  showToast,
+  getConstructionTypeText,
+  getFacilityTypeText,
+  getServiceMethodText,
+  getSuggestedVehicleText,
+  getWaterDistanceText,
+  getAcceptanceMethodText,
+  getCooperationTypeText,
+} from '../../utils'
 import { getTaskDetail, approveTask, rejectTaskAudit, dispatchTask, urgeTask } from '../../api/task'
 import { getProjectDetail } from '../../api/project'
 import { getTeamSimpleList } from '../../api/team'
 import { getVehicleSimpleList } from '../../api/vehicle'
+import { getSchedulesByDate } from '../../api/schedule'
 
 const authStore = useAuthStore()
 
@@ -354,11 +367,46 @@ const taskId = ref<number>(0)
 
 const showArrangeForm = ref(false)
 const auditRemark = ref('')
-const teamOptions = ref<Array<{ value: number; label: string }>>([])
-const vehicleOptions = ref<Array<{ value: number; label: string }>>([])
+const allTeams = ref<Array<{ value: number; label: string }>>([])
+const allVehicles = ref<Array<{ value: number; label: string }>>([])
+const scheduledTeamInfo = ref<Record<number, { vehicleIds: number[]; shiftTypes: string[] }>>({})
 const selectedTeamId = ref<number | undefined>(undefined)
 const selectedVehicleId = ref<number | undefined>(undefined)
 
+const shiftTypeText: Record<string, string> = {
+  day: '白班',
+  night: '夜班',
+  month_plan: '月计划',
+  emergency: '应急',
+}
+
+const isMonthPlanTask = computed(() => task.value?.planId != null)
+
+const teamOptions = computed(() => {
+  const keys = Object.keys(scheduledTeamInfo.value)
+  const base = allTeams.value.map((item) => ({ ...item }))
+  if (keys.length === 0) return base
+  return base
+    .filter((t) => scheduledTeamInfo.value[t.value])
+    .map((t) => {
+      const info = scheduledTeamInfo.value[t.value]
+      const shiftLabels = info?.shiftTypes.map((st) => shiftTypeText[st] || st).join('/')
+      return {
+        ...t,
+        label: shiftLabels ? `${t.label} (${shiftLabels})` : t.label,
+      }
+    })
+})
+
+const vehicleOptions = computed(() => {
+  const teamId = selectedTeamId.value
+  const info = teamId ? scheduledTeamInfo.value[teamId] : null
+  if (!info || info.vehicleIds.length === 0) return allVehicles.value.map((item) => ({ ...item }))
+  return allVehicles.value
+    .filter((v) => info.vehicleIds.includes(v.value))
+    .map((item) => ({ ...item }))
+})
+
 const selectedTeamIndex = computed(() => teamOptions.value.findIndex(t => t.value === selectedTeamId.value))
 const selectedVehicleIndex = computed(() => vehicleOptions.value.findIndex(v => v.value === selectedVehicleId.value))
 const selectedTeamLabel = computed(() => teamOptions.value.find(t => t.value === selectedTeamId.value)?.label || '')
@@ -386,7 +434,8 @@ const taskName = computed(() => {
 
 const constructionTypeText = computed(() => {
   if (!task.value) return ''
-  return task.value.constructionType || taskTypeText(task.value.taskType) || '-'
+  const raw = task.value.constructionType || taskTypeText(task.value.taskType)
+  return raw ? getConstructionTypeText(raw) : '-'
 })
 
 const serviceTimeText = computed(() => {
@@ -403,9 +452,7 @@ const urgencyText = computed(() => {
 })
 
 const coopTypeText = computed(() => {
-  const map: Record<string, string> = { annual: '包年', monthly: '包月', once: '单次', contract: '合同制' }
-  const v = project.value?.cooperationType
-  return v ? map[v] || v : ''
+  return getCooperationTypeText(project.value?.cooperationType)
 })
 
 const projectAddress = computed(() => {
@@ -442,12 +489,12 @@ const partyAContacts = computed<Array<{ name: string; phone: string }>>(() => {
 
 const dispatchedTeamName = computed(() => {
   if (!task.value?.dispatchTeamId) return '未安排'
-  return teamOptions.value.find(t => t.value === Number(task.value.dispatchTeamId))?.label || `班组#${task.value.dispatchTeamId}`
+  return allTeams.value.find(t => t.value === Number(task.value.dispatchTeamId))?.label || `班组#${task.value.dispatchTeamId}`
 })
 
 const dispatchedVehicleName = computed(() => {
   if (!task.value?.dispatchVehicleId) return '未安排'
-  return vehicleOptions.value.find(v => v.value === Number(task.value.dispatchVehicleId))?.label || `车辆#${task.value.dispatchVehicleId}`
+  return allVehicles.value.find(v => v.value === Number(task.value.dispatchVehicleId))?.label || `车辆#${task.value.dispatchVehicleId}`
 })
 
 const auditOperator = computed(() => {
@@ -494,11 +541,14 @@ async function loadDetail() {
     if (res?.projectId) {
       getProjectDetail(res.projectId).then(p => { project.value = p }).catch(() => {})
     }
-    if (teamOptions.value.length === 0) {
-      getTeamSimpleList().then(list => { teamOptions.value = list }).catch(() => {})
+    if (allTeams.value.length === 0) {
+      getTeamSimpleList().then(list => { allTeams.value = list }).catch(() => {})
     }
-    if (vehicleOptions.value.length === 0) {
-      getVehicleSimpleList().then(list => { vehicleOptions.value = list }).catch(() => {})
+    if (allVehicles.value.length === 0) {
+      getVehicleSimpleList().then(list => { allVehicles.value = list }).catch(() => {})
+    }
+    if (res?.planDate) {
+      loadScheduleInfo(res.planDate)
     }
   } catch (e) {
     console.error('加载任务详情失败', e)
@@ -508,6 +558,31 @@ async function loadDetail() {
   }
 }
 
+async function loadScheduleInfo(planDate: string) {
+  try {
+    const res = await getSchedulesByDate(planDate)
+    const schedules: any[] = res || []
+    const map: Record<number, { vehicleIds: number[]; shiftTypes: string[] }> = {}
+    schedules.forEach((s) => {
+      if (s.teamId) {
+        const existing = map[s.teamId] || { vehicleIds: [], shiftTypes: [] }
+        const vehicleIds = (s.vehicleIds || '')
+          .split(',')
+          .map((id: string) => Number(id.trim()))
+          .filter((id: number) => !isNaN(id) && id > 0)
+        existing.vehicleIds = Array.from(new Set([...existing.vehicleIds, ...vehicleIds]))
+        if (s.shiftType && !existing.shiftTypes.includes(s.shiftType)) {
+          existing.shiftTypes.push(s.shiftType)
+        }
+        map[s.teamId] = existing
+      }
+    })
+    scheduledTeamInfo.value = map
+  } catch (error) {
+    console.error('加载当天排班失败', error)
+  }
+}
+
 function auditUser() {
   return {
     auditUserId: authStore.userInfo?.id,
@@ -516,7 +591,14 @@ function auditUser() {
 }
 
 function onTeamChange(e: any) {
-  selectedTeamId.value = teamOptions.value[Number(e.detail.value)]?.value
+  const team = teamOptions.value[Number(e.detail.value)]
+  selectedTeamId.value = team?.value
+  const info = team?.value ? scheduledTeamInfo.value[team.value] : null
+  if (info?.vehicleIds?.length) {
+    selectedVehicleId.value = info.vehicleIds[0]
+  } else {
+    selectedVehicleId.value = undefined
+  }
 }
 
 function onVehicleChange(e: any) {
@@ -618,6 +700,51 @@ async function handleUrge() {
 </script>
 
 <style scoped>
+/* 字段行统一间距 */
+.glass-card .gap-y-3 > .flex.items-start {
+  margin-bottom: 20rpx;
+}
+.glass-card .gap-y-3 > .flex.items-start:last-child {
+  margin-bottom: 0;
+}
+
+/* 字段图标 */
+.field-icon {
+  font-size: 24rpx;
+  color: #9ca3af;
+  width: 32rpx;
+  text-align: center;
+  line-height: 1;
+}
+
+/* 字段标签:加宽以容纳图标和长标签 */
+.glass-card .text-gray-500.text-sm.w-24 {
+  width: 180rpx !important;
+  color: #6b7280 !important;
+  line-height: 1.5;
+}
+
+/* 字段值 */
+.glass-card .text-sm.text-gray-800.flex-1,
+.glass-card .text-sm.flex-1 {
+  color: #1f2937;
+  line-height: 1.5;
+}
+
+/* 地图链接 */
+.link-text {
+  color: #368f6f;
+  text-decoration: underline;
+}
+
+/* 备注 */
+.remark-value {
+  padding: 16rpx;
+  border-radius: 12rpx;
+  background-color: rgba(239, 68, 68, 0.06);
+  color: #dc2626;
+}
+
 .sub-page {
   max-width: 430px;
   margin: 0 auto;
@@ -666,17 +793,19 @@ async function handleUrge() {
 
 .link-text {
   color: #368f6f;
+  text-decoration: underline;
 }
 
 .reject-box {
-  padding: 12px;
-  border-radius: 12px;
+  padding: 24rpx;
+  border-radius: 16rpx;
   background-color: rgba(239, 68, 68, 0.08);
 }
 
 .reject-text {
   font-size: 13px;
   color: #dc2626;
+  line-height: 1.6;
 }
 
 .action-btn {
@@ -691,6 +820,7 @@ async function handleUrge() {
 
 .action-btn-hover {
   opacity: 0.88;
+  transform: scale(0.98);
 }
 
 .action-btn-reject {
@@ -713,9 +843,14 @@ async function handleUrge() {
 .urge-btn {
   display: flex;
   align-items: center;
-  padding: 4px 10px;
+  padding: 8rpx 20rpx;
   border-radius: 999px;
-  background-color: rgba(239, 68, 68, 0.1);
+  background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
+}
+
+.urge-btn-hover {
+  opacity: 0.88;
+  transform: scale(0.96);
 }
 
 .urge-icon {
@@ -738,6 +873,12 @@ async function handleUrge() {
   align-items: center;
   justify-content: center;
   background-color: rgba(164, 216, 152, 0.24);
+  box-shadow: 0 2px 6px rgba(54, 143, 111, 0.12);
+}
+
+.phone-btn-hover {
+  opacity: 0.8;
+  transform: scale(0.92);
 }
 
 .phone-icon {

+ 5 - 7
src/subPackages/pages-dispatch/vehicleList.vue

@@ -37,7 +37,7 @@
           <view class="flex items-center justify-between">
             <view class="flex items-center flex-1 min-w-0 mr-2">
               <view class="icon-chip mr-3">
-                <text class="uni-icons uniui-cart-filled chip-glyph"></text>
+                <text class="uni-icons uniui-gear-filled chip-glyph"></text>
               </view>
               <view class="min-w-0">
                 <text class="row-title truncate">{{ vehicle.plateNumber }}</text>
@@ -140,15 +140,13 @@ function showVehicleDetail(vehicle: Vehicle) {
 
 onMounted(async () => {
   loading.value = true
-  setTimeout(() => {
-    loading.value = false
-  }, 300)
+  await vehicleStore.fetchVehicles()
+  loading.value = false
 })
 
 onPullDownRefresh(async () => {
-  setTimeout(() => {
-    uni.stopPullDownRefresh()
-  }, 500)
+  await vehicleStore.fetchVehicles()
+  uni.stopPullDownRefresh()
 })
 </script>
 

+ 56 - 18
src/types/index.ts

@@ -28,15 +28,6 @@ export interface User {
 export type ProjectStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'
 /** 合作类型(后台 cooperation_type 字典) */
 export type CooperationType = 'annual' | 'single' | 'quarter' | 'monthly'
-/** 所属行业(后台 industry 字典) */
-export type Industry =
-  | 'residential'
-  | 'commercial'
-  | 'industrial'
-  | 'municipal'
-  | 'medical'
-  | 'education'
-  | 'catering'
 /** 设施类型(后台 facility_type 字典) */
 export type FacilityType = 'septic_tank' | 'direct_pump' | 'grease_pipe' | 'pvc_pipe'
 
@@ -46,18 +37,28 @@ export interface Contact {
   role: string
 }
 
+/** 位置联系人(后台 location_contacts JSON 中的单项) */
+export interface LocationContact {
+  name: string
+  phone: string
+}
+
 /** 项目服务点(后台 service_points JSON 中的单项) */
 export interface ServicePoint {
+  id?: number
   name: string
   address?: string
   contact?: string
   phone?: string
+  /** 对应服务项(与后台 ProjectForm 对齐) */
+  serviceItem?: string
+  /** @deprecated 旧字段,请使用 serviceItem */
   serviceType?: string
 }
 
 /**
  * 项目(对齐后台 BizProject 实体)
- * 注意:列表/详情返回的主键为 projectId;facilityTypes、servicePoints 后端以 JSON 字符串存储。
+ * 注意:列表/详情返回的主键为 projectId;facilityTypes、servicePoints、locationContacts 后端以 JSON 字符串存储。
  */
 export interface Project {
   projectId: number | string
@@ -73,18 +74,15 @@ export interface Project {
   detailAddress?: string
   latitude?: string
   longitude?: string
+  /** 主联系人(通常由 locationContacts 第一个联系人或客户联系人推导) */
   primaryContactName?: string
   primaryContactPhone?: string
-  backupContactName?: string
-  backupContactPhone?: string
-  specialContactReq?: string
-  industry?: Industry
-  occupancyRate?: number
-  buildingArea?: number
-  contractStartDate?: string
-  contractEndDate?: string
+  /** 位置联系人 JSON 字符串或对象数组 */
+  locationContacts?: string | LocationContact[]
   projectDescription?: string
   contractAmount?: number
+  serviceStartDate?: string
+  serviceEndDate?: string
   servicePoints?: string | ServicePoint[]
   customerId?: number | string
   customerName?: string
@@ -198,6 +196,8 @@ export interface Task {
   serviceType?: string
   facilityType?: string
   serviceMode?: string
+  servicePointId?: number
+  servicePointName?: string
   faultLocation?: string
   faultType?: string
   faultDesc?: string
@@ -207,6 +207,10 @@ export interface Task {
   vehicle?: string
   vehicleNo?: string
   team?: string
+  /** 调度班组名称(后台 teamName) */
+  teamName?: string
+  /** 调度车辆车牌号(后台 plateNo) */
+  plateNo?: string
   assignee?: string
   staffName?: string
   applicant?: string
@@ -215,9 +219,17 @@ export interface Task {
   rejectReason?: string
   acceptType?: string
   acceptRequirement?: string
+  /** 预计作业量,对应后台 estimatedWorkload */
   estimatedQuantity?: string
+  estimatedWorkload?: string
+  /** 预计工时,对应后台 estimatedDuration */
   estimatedHours?: string
+  estimatedDuration?: string
+  /** 施工助力信息,对应后台 assistantInfo */
   constructionHelp?: string
+  assistantInfo?: string
+  /** 验收要求,对应后台 acceptanceRequirement */
+  acceptanceReq?: string
   waterSource?: string
   septicTank?: string
   previousConstruction?: string
@@ -233,6 +245,7 @@ export interface Task {
   serviceDate?: string
   serviceTime?: string
   planDate?: string
+  planEndDate?: string
   planTimeRange?: string
   /** SOP 当前步骤 0-8 */
   currentStep?: number
@@ -249,11 +262,36 @@ export interface Task {
   cleanTime?: string
   inspectTime?: string
   createTime: string
+  updateTime?: string
   timeline?: TimelineRecord[]
   /** 后台操作日志 */
   logList?: any[]
   location?: { lat: number; lng: number }
   mediaList?: { type: 'image' | 'video'; url: string }[]
+  /** 施工设施 */
+  constructionFacility?: string
+  /** 服务方式 */
+  serviceMethod?: string
+  /** 取水距离 */
+  waterDistance?: string
+  /** 化粪池排口是否正常 1-是 0-否 */
+  outletNormal?: number
+  /** 是否有前期施工关联 1-是 0-否 */
+  hasPreviousWork?: number
+  /** 验收方式 */
+  acceptanceMethod?: string
+  /** 建议出车,对应后台 toolRequirement */
+  suggestedVehicles?: string[]
+  toolRequirement?: string
+  /** 结算类型 contract-合同内 extra-额外 */
+  settlementType?: 'contract' | 'extra'
+  /** 结算方式 */
+  settlementMethod?: string
+  /** 结算金额 */
+  settlementAmount?: number
+  /** 扩展字段 JSON 字符串 */
+  extraData?: string
+  remark?: string
 }
 
 export type ScheduleStatus = 'scheduled' | 'completed' | 'cancelled'

+ 219 - 11
src/utils/index.ts

@@ -1,5 +1,92 @@
 import type { ToastOptions, ModalOptions } from '@/types'
 
+export interface ChooseMediaFile {
+  tempFilePath: string
+  fileType?: 'image' | 'video'
+}
+
+export interface ChooseMediaOptions {
+  count?: number
+  mediaType?: ('image' | 'video')[]
+  sourceType?: string[]
+  success?: (res: { tempFiles: ChooseMediaFile[] }) => void
+  fail?: (err: any) => void
+  complete?: () => void
+}
+
+/**
+ * 兼容 uni.chooseMedia 在 H5 端不存在的问题
+ * - 支持 chooseMedia 的环境直接使用
+ * - H5 端降级为 uni.chooseImage / uni.chooseVideo
+ */
+export function chooseMediaCompat(options: ChooseMediaOptions): void {
+  if (typeof uni.chooseMedia === 'function') {
+    ;(uni as any).chooseMedia(options)
+    return
+  }
+
+  const mediaType = options.mediaType || ['image', 'video']
+  const onlyImage = mediaType.includes('image') && !mediaType.includes('video')
+  const onlyVideo = mediaType.includes('video') && !mediaType.includes('image')
+
+  if (onlyVideo && typeof uni.chooseVideo === 'function') {
+    uni.chooseVideo({
+      sourceType: options.sourceType,
+      success: (res: any) => {
+        options.success?.({
+          tempFiles: [{ tempFilePath: res.tempFilePath, fileType: 'video' }],
+        })
+      },
+      fail: options.fail,
+      complete: options.complete,
+    })
+    return
+  }
+
+  // H5 端 chooseImage 支持多选,chooseVideo 仅支持单个
+  // 当同时需要图片和视频时,为保证流程可用,默认按图片处理
+  uni.chooseImage({
+    count: options.count || 9,
+    sourceType: options.sourceType,
+    success: (res: any) => {
+      const tempFiles = (res.tempFiles || res.tempFilePaths || []).map((f: any) => ({
+        tempFilePath: f.path || f.tempFilePath || f,
+        fileType: 'image' as const,
+      }))
+      options.success?.({ tempFiles })
+    },
+    fail: options.fail,
+    complete: options.complete,
+  })
+}
+
+/**
+ * 安全隐藏原生 tabBar
+ * 非 tabBar 页面调用会抛错,静默处理避免阻塞业务流程
+ */
+export function safeHideTabBar(options?: { animation?: boolean; repeat?: number }): void {
+  const animation = options?.animation ?? false
+  const repeat = options?.repeat ?? 3
+
+  function hideOnce() {
+    try {
+      const res = uni.hideTabBar({ animation, fail: () => {} }) as any
+      if (res && typeof res.catch === 'function') {
+        res.catch(() => {})
+      }
+    } catch {
+      // ignore
+    }
+  }
+
+  hideOnce()
+  if (repeat > 1) {
+    for (let i = 1; i < repeat; i++) {
+      setTimeout(hideOnce, i * 100)
+    }
+  }
+}
+
 /**
  * 格式化日期时间
  */
@@ -24,6 +111,16 @@ export function formatDate(date: string | number | Date, format = 'YYYY-MM-DD HH
     .replace('ss', second)
 }
 
+/**
+ * 格式化金额
+ */
+export function formatMoney(amount?: number | string | null): string {
+  if (amount === undefined || amount === null || amount === '') return '-'
+  const num = Number(amount)
+  if (isNaN(num)) return '-'
+  return '¥' + num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+}
+
 /**
  * 相对时间描述
  */
@@ -172,7 +269,13 @@ export function redirectTo(url: string): void {
 }
 
 export function switchTab(url: string): void {
+  // #ifdef APP-PLUS
+  // App 端使用 reLaunch 兼容未配置原生 tabBar 的场景
+  uni.reLaunch({ url })
+  // #endif
+  // #ifndef APP-PLUS
   uni.switchTab({ url })
+  // #endif
 }
 
 export function goBack(delta = 1): void {
@@ -306,7 +409,8 @@ export function getPersonnelStatusText(status: string): string {
 /**
  * 合作类型文本(后台 cooperation_type:annual/single/quarter/monthly)
  */
-export function getCooperationTypeText(type: string): string {
+export function getCooperationTypeText(type: string | undefined | null): string {
+  if (!type) return '-'
   const map: Record<string, string> = {
     annual: '包年',
     single: '单次',
@@ -337,17 +441,103 @@ export function getIndustryText(industry: string): string {
   return map[industry] || industry || '-'
 }
 
+import { useDictStore } from '@/stores/dict'
+
 /**
- * 设施类型文本(后台 facility_type 字典)
+ * 设施类型文本(项目:facility_type;任务:construction_facility)
+ * 支持单值、逗号/顿号分隔、JSON 数组,并对常见英文 code 做兜底映射
  */
-export function getFacilityTypeText(type: string): string {
+export function getFacilityTypeText(type: string | undefined | null): string {
+  if (!type) return '-'
+
   const map: Record<string, string> = {
     septic_tank: '化粪池',
     direct_pump: '直抽直排',
     grease_pipe: '油污管+化粪池',
-    pvc_pipe: 'PVC+化粪池'
+    pvc_pipe: 'PVC+化粪池',
+    grease_pipe_septic_tank: '油污管+化粪池',
+    pvc_pipe_septic_tank: 'PVC+化粪池'
   }
-  return map[type] || type || '-'
+
+  function translateOne(raw: string): string {
+    const key = raw.trim().toLowerCase().replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/\s+/g, '_')
+    if (!key) return ''
+    if (map[key]) return map[key]
+
+    const dictStore = useDictStore()
+    const label = dictStore.getLabel('facility_type', key) || dictStore.getLabel('construction_facility', key)
+    const isCodeLike = /^[a-z0-9_]+$/.test(label)
+    if (label && label !== key && !isCodeLike) return label
+
+    return map[key] || key
+  }
+
+  let items: string[] = []
+  const normalized = String(type).trim()
+  if (normalized.startsWith('[')) {
+    try {
+      const parsed = JSON.parse(normalized)
+      items = Array.isArray(parsed) ? parsed.map(String) : [normalized]
+    } catch {
+      items = [normalized]
+    }
+  } else {
+    items = normalized.split(/[,,、/]+/)
+  }
+
+  const translated = items.map(translateOne).filter(Boolean)
+  return translated.length > 0 ? translated.join('、') : normalized
+}
+
+/**
+ * 施工类型文本(construction_type 字典)
+ */
+export function getConstructionTypeText(type: string | undefined | null): string {
+  if (!type) return '-'
+  const dictStore = useDictStore()
+  const label = dictStore.getLabel('construction_type', type)
+  if (label && label !== type) return label
+
+  const map: Record<string, string> = {
+    dredge: '疏通',
+    clean: '清掏',
+    inspect: '排查',
+    repair: '维修',
+    emergency: '应急'
+  }
+  return map[type] || type
+}
+
+/**
+ * 服务方式文本(service_method 字典)
+ */
+export function getServiceMethodText(value: string | undefined | null): string {
+  if (!value) return '-'
+  return useDictStore().getLabel('service_method', value)
+}
+
+/**
+ * 建议出车文本(suggested_vehicle 字典)
+ */
+export function getSuggestedVehicleText(value: string | undefined | null): string {
+  if (!value) return '-'
+  return useDictStore().getLabel('suggested_vehicle', value)
+}
+
+/**
+ * 取水距离文本(water_distance 字典)
+ */
+export function getWaterDistanceText(value: string | undefined | null): string {
+  if (!value) return '-'
+  return useDictStore().getLabel('water_distance', value)
+}
+
+/**
+ * 验收方式文本(acceptance_method 字典)
+ */
+export function getAcceptanceMethodText(value: string | undefined | null): string {
+  if (!value) return '-'
+  return useDictStore().getLabel('acceptance_method', value)
 }
 
 /**
@@ -364,6 +554,20 @@ export function parseJsonArray<T = any>(value: string | T[] | undefined | null):
   }
 }
 
+/**
+ * 解析位置联系人 JSON 字符串/数组
+ */
+export function parseLocationContacts(value: string | { name: string; phone: string }[] | undefined | null): { name: string; phone: string }[] {
+  return parseJsonArray<{ name: string; phone: string }>(value)
+}
+
+/**
+ * 将位置联系人数组序列化为 JSON 字符串
+ */
+export function formatLocationContacts(contacts: { name: string; phone: string }[]): string {
+  return JSON.stringify(contacts || [])
+}
+
 /**
  * 项目状态文本(后台 project_status:pending/in_progress/completed/cancelled)
  */
@@ -487,11 +691,15 @@ export function getPaymentMethodText(method: string): string {
 }
 
 /**
- * 金额格式化(元,保留两位)
+ * 解析 extraData 扩展字段 JSON 字符串,容错返回对象
  */
-export function formatMoney(amount: number | string | undefined | null): string {
-  if (amount === undefined || amount === null || amount === '') return '-'
-  const num = typeof amount === 'string' ? Number(amount) : amount
-  if (isNaN(num)) return '-'
-  return `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
+export function parseExtraData<T = Record<string, any>>(value?: string | T | null): T {
+  if (!value) return {} as T
+  if (typeof value !== 'string') return value as T
+  try {
+    return JSON.parse(value) as T
+  } catch {
+    return {} as T
+  }
 }
+

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor