Kaynağa Gözat

feat: 对齐任务字段与后台管理端,接入 SOP 步骤执行及文件上传能力

zhangzhicheng 1 hafta önce
ebeveyn
işleme
3e0935e64c

+ 241 - 0
progress-report.html

@@ -0,0 +1,241 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>清道夫App — 开发进度报告</title>
+<script src="https://cdn.tailwindcss.com"></script>
+<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
+<style>
+  body { background:#f5f6f8; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; }
+  .card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 2px 8px rgba(0,0,0,.04); }
+  .bar-track { background:#eef0f3; border-radius:9999px; height:10px; overflow:hidden; }
+  .tag { font-size:12px; padding:2px 10px; border-radius:20px; }
+</style>
+</head>
+<body>
+<div class="max-w-5xl mx-auto px-4 py-8">
+
+  <!-- 头部 -->
+  <header class="mb-8">
+    <div class="flex items-center gap-3 mb-2">
+      <div class="w-11 h-11 rounded-xl bg-blue-600 text-white flex items-center justify-center text-xl">
+        <i class="fa fa-leaf"></i>
+      </div>
+      <div>
+        <h1 class="text-2xl font-bold text-gray-900">清道夫App — 开发进度报告</h1>
+        <p class="text-sm text-gray-500">环境服务管理平台 · uni-app + Vue 3 + TypeScript + Pinia + Tailwind</p>
+      </div>
+    </div>
+    <p class="text-xs text-gray-400">报告生成日期:2026-07-13 | 数据基准:master 分支最新提交(UI2.0 + 客户/合同模块)</p>
+  </header>
+
+  <!-- 总览指标 -->
+  <section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
+    <div class="card p-5">
+      <p class="text-sm text-gray-500 mb-1"><i class="fa fa-file-code-o mr-1 text-blue-500"></i>页面总数</p>
+      <p class="text-3xl font-bold text-gray-900">54<span class="text-base font-normal text-gray-400 ml-1">页</span></p>
+      <p class="text-xs text-gray-400 mt-1">8 主包 + 46 子包</p>
+    </div>
+    <div class="card p-5">
+      <p class="text-sm text-gray-500 mb-1"><i class="fa fa-check-circle mr-1 text-blue-500"></i>原型完成度</p>
+      <p class="text-3xl font-bold text-gray-900">100<span class="text-base font-normal text-gray-400 ml-1">%</span></p>
+      <p class="text-xs text-gray-400 mt-1">54/54 原型页面已实现</p>
+    </div>
+    <div class="card p-5">
+      <p class="text-sm text-gray-500 mb-1"><i class="fa fa-plug mr-1 text-blue-500"></i>API 模块</p>
+      <p class="text-3xl font-bold text-gray-900">12<span class="text-base font-normal text-gray-400 ml-1">个</span></p>
+      <p class="text-xs text-gray-400 mt-1">客户/合同已接真实接口</p>
+    </div>
+    <div class="card p-5">
+      <p class="text-sm text-gray-500 mb-1"><i class="fa fa-database mr-1 text-blue-500"></i>状态管理</p>
+      <p class="text-3xl font-bold text-gray-900">11<span class="text-base font-normal text-gray-400 ml-1">个 Store</span></p>
+      <p class="text-xs text-gray-400 mt-1">Pinia 全覆盖</p>
+    </div>
+  </section>
+
+  <!-- 各阶段进度 -->
+  <section class="card p-6 mb-8">
+    <h2 class="text-lg font-bold text-gray-900 mb-5"><i class="fa fa-tasks mr-2 text-blue-600"></i>开发阶段进度</h2>
+    <div class="space-y-5">
+      <div>
+        <div class="flex justify-between text-sm mb-1.5">
+          <span class="text-gray-700 font-medium">阶段一 · 基础架构与 24 页小程序风格重构</span>
+          <span class="text-blue-600 font-medium">已完成</span>
+        </div>
+        <div class="bar-track"><div class="h-full bg-blue-500 rounded-full" style="width:100%"></div></div>
+      </div>
+      <div>
+        <div class="flex justify-between text-sm mb-1.5">
+          <span class="text-gray-700 font-medium">阶段二 · 补齐 12 个缺失页面(基本信息/消息/知识库等)</span>
+          <span class="text-blue-600 font-medium">已完成</span>
+        </div>
+        <div class="bar-track"><div class="h-full bg-blue-500 rounded-full" style="width:100%"></div></div>
+      </div>
+      <div>
+        <div class="flex justify-between text-sm mb-1.5">
+          <span class="text-gray-700 font-medium">阶段三 · 高级组件(图片上传/地图/电子签名/图表)</span>
+          <span class="text-blue-600 font-medium">已完成</span>
+        </div>
+        <div class="bar-track"><div class="h-full bg-blue-500 rounded-full" style="width:100%"></div></div>
+      </div>
+      <div>
+        <div class="flex justify-between text-sm mb-1.5">
+          <span class="text-gray-700 font-medium">UI 2.0 改版 + 登录页增强(记住密码/密码可见性)</span>
+          <span class="text-blue-600 font-medium">已完成</span>
+        </div>
+        <div class="bar-track"><div class="h-full bg-blue-500 rounded-full" style="width:100%"></div></div>
+      </div>
+      <div>
+        <div class="flex justify-between text-sm mb-1.5">
+          <span class="text-gray-700 font-medium">客户管理 / 合同管理模块(含回款记录、跟进记录)</span>
+          <span class="text-blue-600 font-medium">已完成</span>
+        </div>
+        <div class="bar-track"><div class="h-full bg-blue-500 rounded-full" style="width:100%"></div></div>
+      </div>
+      <div>
+        <div class="flex justify-between text-sm mb-1.5">
+          <span class="text-gray-700 font-medium">后端联调收尾(实时定位、消息推送、Mock 清理)</span>
+          <span class="text-amber-600 font-medium">进行中 · 约 60%</span>
+        </div>
+        <div class="bar-track"><div class="h-full bg-amber-400 rounded-full" style="width:60%"></div></div>
+      </div>
+    </div>
+  </section>
+
+  <!-- 三端角色覆盖 -->
+  <section class="card p-6 mb-8">
+    <h2 class="text-lg font-bold text-gray-900 mb-5"><i class="fa fa-users mr-2 text-blue-600"></i>三端角色页面覆盖</h2>
+    <div class="grid md:grid-cols-3 gap-4">
+      <div class="border border-gray-200 rounded-xl p-4">
+        <div class="flex items-center justify-between mb-3">
+          <span class="font-medium text-gray-800"><i class="fa fa-briefcase mr-1.5 text-blue-500"></i>销售端</span>
+          <span class="tag bg-blue-50 text-blue-600 border border-blue-200">21 页 · 完成</span>
+        </div>
+        <p class="text-xs text-gray-500 leading-relaxed">首页、我的任务、项目库、发布任务、消息通知、任务提醒、知识库、个人中心等</p>
+      </div>
+      <div class="border border-gray-200 rounded-xl p-4">
+        <div class="flex items-center justify-between mb-3">
+          <span class="font-medium text-gray-800"><i class="fa fa-calendar mr-1.5 text-blue-500"></i>调度端</span>
+          <span class="tag bg-blue-50 text-blue-600 border border-blue-200">19 页 · 完成</span>
+        </div>
+        <p class="text-xs text-gray-500 leading-relaxed">排班管理、人员/车辆管理、应急班组、在岗人员、可视化大屏、任务审核等</p>
+      </div>
+      <div class="border border-gray-200 rounded-xl p-4">
+        <div class="flex items-center justify-between mb-3">
+          <span class="font-medium text-gray-800"><i class="fa fa-wrench mr-1.5 text-blue-500"></i>施工端</span>
+          <span class="tag bg-blue-50 text-blue-600 border border-blue-200">13 页 · 完成</span>
+        </div>
+        <p class="text-xs text-gray-500 leading-relaxed">每日任务、SOP 施工流程、历史/明日任务、电子签名验收等</p>
+      </div>
+    </div>
+  </section>
+
+  <!-- 待完善事项 -->
+  <section class="card p-6 mb-8">
+    <h2 class="text-lg font-bold text-gray-900 mb-1"><i class="fa fa-exclamation-circle mr-2 text-amber-500"></i>待完善事项</h2>
+    <p class="text-sm text-gray-500 mb-5">按优先级排列,建议自上而下推进</p>
+
+    <div class="space-y-4">
+      <!-- P0 -->
+      <div class="border border-red-100 rounded-xl p-4 bg-red-50/40">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-red-100 text-red-600 border border-red-200 mt-0.5 shrink-0">P0 · 高</span>
+          <div>
+            <p class="font-medium text-gray-900">后端地址硬编码 <code class="text-xs bg-white border border-gray-200 px-1.5 py-0.5 rounded text-red-600">http://localhost:8080/api</code></p>
+            <p class="text-sm text-gray-600 mt-1"><code class="text-xs">src/api/request.ts</code> 中 BASE_URL 写死为本地地址,真机/发布环境无法访问。建议按环境(dev/test/prod)配置 Base URL,并在 manifest 或环境变量中管理。</p>
+          </div>
+        </div>
+      </div>
+
+      <div class="border border-red-100 rounded-xl p-4 bg-red-50/40">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-red-100 text-red-600 border border-red-200 mt-0.5 shrink-0">P0 · 高</span>
+          <div>
+            <p class="font-medium text-gray-900">登录态失效缺少统一拦截</p>
+            <p class="text-sm text-gray-600 mt-1">request 封装已带 Bearer Token,但建议确认 401/Token 过期时是否统一跳转登录页并清空本地存储,避免各页面各自处理。</p>
+          </div>
+        </div>
+      </div>
+
+      <!-- P1 -->
+      <div class="border border-amber-100 rounded-xl p-4 bg-amber-50/40">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-amber-100 text-amber-700 border border-amber-200 mt-0.5 shrink-0">P1 · 中</span>
+          <div>
+            <p class="font-medium text-gray-900">实时定位与消息推送仍停留在"框架就绪"</p>
+            <p class="text-sm text-gray-600 mt-1">DEV_LOG 标记 <code class="text-xs">uni.getLocation</code> / <code class="text-xs">uni-push</code> 待接入真实设备与后端。施工轨迹上报、任务到达提醒等依赖此能力,需与后端排期联调。</p>
+          </div>
+        </div>
+      </div>
+
+      <div class="border border-amber-100 rounded-xl p-4 bg-amber-50/40">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-amber-100 text-amber-700 border border-amber-200 mt-0.5 shrink-0">P1 · 中</span>
+          <div>
+            <p class="font-medium text-gray-900">调度端发布任务页仍引用 Mock 数据</p>
+            <p class="text-sm text-gray-600 mt-1"><code class="text-xs">subPackages/pages-dispatch/publishTask.vue</code> 是全项目唯一还在引用 <code class="text-xs">src/mock</code> 的页面;客户/合同模块已全面切换真实接口,建议同步迁移并清理 mock 中"旧版演示数据"。</p>
+          </div>
+        </div>
+      </div>
+
+      <div class="border border-amber-100 rounded-xl p-4 bg-amber-50/40">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-amber-100 text-amber-700 border border-amber-200 mt-0.5 shrink-0">P1 · 中</span>
+          <div>
+            <p class="font-medium text-gray-900">客户/合同入口不在 TabBar,需确认导航可达性</p>
+            <p class="text-sm text-gray-600 mt-1"><code class="text-xs">pages/customer</code>、<code class="text-xs">pages/contract</code> 位于主包但 TabBar 仅含首页/任务/排班/我的。若仅从首页快捷入口进入,建议在"我的"或首页固化入口,并验证销售端角色的可见权限控制。</p>
+          </div>
+        </div>
+      </div>
+
+      <!-- P2 -->
+      <div class="border border-gray-200 rounded-xl p-4">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-gray-100 text-gray-600 border border-gray-300 mt-0.5 shrink-0">P2 · 低</span>
+          <div>
+            <p class="font-medium text-gray-900">开发文档过期:DEV_LOG.md 停留在 2026-06-22</p>
+            <p class="text-sm text-gray-600 mt-1">之后的 UI 2.0 改版、登录页增强、客户/合同模块均未记录。建议补一轮更新,或在 README 中标注文档时效。</p>
+          </div>
+        </div>
+      </div>
+
+      <div class="border border-gray-200 rounded-xl p-4">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-gray-100 text-gray-600 border border-gray-300 mt-0.5 shrink-0">P2 · 低</span>
+          <div>
+            <p class="font-medium text-gray-900">无自动化测试</p>
+            <p class="text-sm text-gray-600 mt-1">已安装 vitest / @vue/test-utils 但 package.json 无 test 脚本、无测试文件。关键链路(登录鉴权、客户/合同表单提交、任务状态流转)建议补最小单测。</p>
+          </div>
+        </div>
+      </div>
+
+      <div class="border border-gray-200 rounded-xl p-4">
+        <div class="flex items-start gap-3">
+          <span class="tag bg-gray-100 text-gray-600 border border-gray-300 mt-0.5 shrink-0">P2 · 低</span>
+          <div>
+            <p class="font-medium text-gray-900">重复页面可考虑收敛</p>
+            <p class="text-sm text-gray-600 mt-1">noticeList / noticeDetail / notification / messageList / knowledgeList 等在 common、sales、dispatch、construction 多个子包各有一份。功能一致的建议统一引用 common 版本,减少维护成本(当前约 91 个 .vue 文件)。</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+
+  <!-- 建议下一步 -->
+  <section class="card p-6 mb-4">
+    <h2 class="text-lg font-bold text-gray-900 mb-4"><i class="fa fa-road mr-2 text-blue-600"></i>建议下一步路线</h2>
+    <ol class="space-y-3 text-sm text-gray-700">
+      <li class="flex gap-3"><span class="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center shrink-0">1</span><span><b>环境化 Base URL</b>:区分开发/生产环境,完成真机预览验证。</span></li>
+      <li class="flex gap-3"><span class="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center shrink-0">2</span><span><b>清除 Mock 残留</b>:迁移 dispatch/publishTask,删除 mock 旧数据。</span></li>
+      <li class="flex gap-3"><span class="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center shrink-0">3</span><span><b>联调定位与推送</b>:与后端确认接口与证书,落地实时定位 + uni-push。</span></li>
+      <li class="flex gap-3"><span class="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center shrink-0">4</span><span><b>补关键链路测试与文档</b>:登录、合同回款、任务流转三条链路;同步更新 DEV_LOG。</span></li>
+    </ol>
+  </section>
+
+  <footer class="text-center text-xs text-gray-400 py-6">
+    清道夫App 进度报告 · 基于代码库静态分析生成(91 个 Vue 文件 / 12 个 API 模块 / 11 个 Store)
+  </footer>
+</div>
+</body>
+</html>

+ 1 - 0
src/api/contract.ts

@@ -3,6 +3,7 @@ import type { Contract, ContractStatus, PageResult, PaymentMethod } from '../typ
 
 export interface ContractParams {
   contractNo: string
+  contractName?: string
   customerId: number | string
   customerName?: string
   projectId?: number | string

+ 103 - 0
src/api/file.ts

@@ -0,0 +1,103 @@
+import { BASE_URL } from '../config'
+import { get, del } from './request'
+
+function parseUploadResponse(res: UniApp.UploadFileSuccessCallbackResult): { code?: number; msg?: string; data?: any } {
+  try {
+    return JSON.parse(typeof res.data === 'string' ? res.data : '{}')
+  } catch {
+    return {}
+  }
+}
+
+/**
+ * 通用文件上传
+ * @param filePath 本地临时文件路径
+ * @returns 上传后的访问 URL
+ */
+export function uploadFile(filePath: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: `${BASE_URL}/file/upload`,
+      filePath,
+      name: 'file',
+      header: token ? { Authorization: `Bearer ${token}` } : {},
+      success: (res) => {
+        const result = parseUploadResponse(res)
+        if (res.statusCode === 200 && result.code === 200 && result.data?.url) {
+          resolve(result.data.url)
+        } else {
+          reject(new Error(result.msg || '上传失败'))
+        }
+      },
+      fail: (err) => {
+        reject(new Error(err.errMsg || '上传失败'))
+      },
+    })
+  })
+}
+
+/**
+ * 任务照片上传
+ * @param filePath 本地临时文件路径
+ * @param taskId 任务ID
+ * @param step SOP步骤编码(英文 code)
+ * @param phase admin 前端照片分组 phase
+ * @returns 上传后的访问 URL
+ */
+export function uploadTaskPhoto(
+  filePath: string,
+  taskId: number,
+  step: string,
+  phase?: string
+): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: `${BASE_URL}/task-photo/upload`,
+      filePath,
+      name: 'file',
+      formData: {
+        taskId: String(taskId),
+        step,
+        phase: phase || step,
+      },
+      header: token ? { Authorization: `Bearer ${token}` } : {},
+      success: (res) => {
+        const result = parseUploadResponse(res)
+        if (res.statusCode === 200 && result.code === 200 && result.data) {
+          resolve(result.data)
+        } else {
+          reject(new Error(result.msg || '上传失败'))
+        }
+      },
+      fail: (err) => {
+        reject(new Error(err.errMsg || '上传失败'))
+      },
+    })
+  })
+}
+
+/** 查询任务照片列表 */
+export function getTaskPhotos(taskId: number): Promise<any[]> {
+  return get(`/task-photo/list/${taskId}`)
+}
+
+/** 删除任务照片 */
+export function deleteTaskPhoto(photoId: number): Promise<any> {
+  return del(`/task-photo/${photoId}`)
+}
+
+/** 单张任务照片结构 */
+export interface TaskPhoto {
+  photoId?: number
+  taskId?: number
+  phase?: string
+  stepCode?: string
+  photoType?: string
+  photoUrl?: string
+  photoTime?: string
+  remark?: string
+  uploadUserId?: number
+  createTime?: string
+}

+ 34 - 9
src/api/notice.ts

@@ -1,5 +1,6 @@
 import { get, post, put } from './request'
 
+// ==================== 通知公告 API(后台 /notice)====================
 export function getNoticeList(pageNum = 1, pageSize = 10, type?: string) {
   return get('/notice/list', { pageNum, pageSize, type })
 }
@@ -8,22 +9,46 @@ export function getNoticeDetail(noticeId: number) {
   return get(`/notice/${noticeId}`)
 }
 
-export function getMessageList(pageNum = 1, pageSize = 10) {
-  return get('/message/list', { pageNum, pageSize })
+// ==================== 消息通知 API(后台 /message)====================
+export interface Message {
+  messageId?: number
+  title: string
+  content: string
+  messageType?: string
+  type?: string
+  receiverId?: number
+  senderId?: number
+  senderName?: string
+  isRead?: number
+  readStatus?: number
+  createTime?: string
+  extraData?: string
+}
+
+export function getMessageList(pageNum = 1, pageSize = 10, type?: string) {
+  const params: Record<string, any> = { pageNum, pageSize }
+  if (type && type !== 'all') {
+    params.type = type
+  }
+  return get<{ rows: Message[]; total: number }>('/message/list', params)
 }
 
 export function getMessageDetail(messageId: number) {
-  return get(`/message/${messageId}`)
+  return get<Message>(`/message/${messageId}`)
+}
+
+export function markMessageRead(messageId: number) {
+  return post(`/message/read/${messageId}`, {})
 }
 
-export function markNoticeRead(noticeId: number) {
-  return put(`/notice/${noticeId}/read`)
+export function markAllMessageRead() {
+  return post('/message/read-all', {})
 }
 
-export function markAllRead() {
-  return put('/notice/read-all')
+export function getUnreadMessageCount() {
+  return get<number>('/message/unread-count')
 }
 
-export function getUnreadCount() {
-  return get('/notice/unread-count')
+export function sendMessage(data: Message) {
+  return post('/message/send', data)
 }

+ 44 - 3
src/api/task.ts

@@ -12,6 +12,7 @@ export interface TaskParams {
   faultLocation?: string
   faultType?: string
   customerWords?: string
+  serviceContent?: string
   constructionType?: string
   constructionFacility?: string
   serviceMethod?: string
@@ -23,6 +24,26 @@ export interface TaskParams {
   acceptanceMethod?: string
   remark?: string
   extraData?: 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
   [key: string]: any
 }
 
@@ -50,8 +71,24 @@ export function deleteTask(taskId: number) {
   return del(`/task/${taskId}`)
 }
 
-export function advanceTaskStep(taskId: number, step: number, data?: Record<string, any>) {
-  return post(`/task/${taskId}/step/${step}`, data)
+export interface ExecuteSopStepData {
+  taskId: number
+  stepCode: string
+  action?: string
+  remark?: string
+  operatorId?: number
+  operatorName?: string
+  photos?: string[]
+  /** 步骤业务数据 JSON 字符串(与 admin 前端字段对齐) */
+  data?: string
+}
+
+export function executeSopStep(data: ExecuteSopStepData) {
+  return post('/sop/execute-step', data)
+}
+
+export function getSopSteps(taskId: number) {
+  return get(`/sop/steps/${taskId}`)
 }
 
 export function urgeTask(taskId: number, urgeReason: string, operatorId: number, operatorName: string) {
@@ -59,7 +96,11 @@ export function urgeTask(taskId: number, urgeReason: string, operatorId: number,
 }
 
 export function cancelTask(taskId: number, cancelReason: string, operatorId: number, operatorName: string) {
-  return post(`/task/${taskId}/cancel`, { taskId, cancelReason, operatorId, operatorName })
+  return put(`/task/${taskId}/cancel`, { taskId, cancelReason, operatorId, operatorName })
+}
+
+export function completeTask(taskId: number, data: { taskId: number; remark?: string; operatorId?: number; operatorName?: string }) {
+  return post(`/task/${taskId}/complete`, data)
 }
 
 export function getTaskLogs(taskId: number) {

+ 111 - 24
src/components/common/ImageUploader.vue

@@ -2,59 +2,96 @@
   <view class="image-uploader">
     <!-- 已上传图片列表 -->
     <view class="flex flex-wrap gap-2">
-      <view 
-        v-for="(url, index) in modelValue" 
-        :key="index" 
+      <view
+        v-for="(item, index) in normalizedList"
+        :key="item.id || item.url || index"
         class="relative w-20 h-20 rounded-lg overflow-hidden border border-gray-200"
       >
-        <image 
-          :src="url" 
-          class="w-full h-full object-cover" 
+        <image
+          :src="item.url"
+          style="width: 160rpx; height: 160rpx;"
+          class="object-cover block"
           mode="aspectFill"
           @click="previewImage(index)"
         />
-        <view 
+        <view
           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)"
         >
           <text class="uni-icons uniui-close text-white text-xs"></text>
         </view>
       </view>
-      
+
       <!-- 上传按钮 -->
-      <view 
-        v-if="modelValue.length < maxCount"
+      <view
+        v-if="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"
       >
         <text class="uni-icons uniui-camera-filled text-gray-400 text-xl"></text>
-        <text class="text-xs text-gray-400 mt-1">{{ modelValue.length }}/{{ maxCount }}</text>
+        <text class="text-xs text-gray-400 mt-1">{{ normalizedList.length }}/{{ maxCount }}</text>
       </view>
     </view>
-    
+
     <!-- 提示文字 -->
     <text v-if="tip" class="text-xs text-gray-400 mt-2 block">{{ tip }}</text>
   </view>
 </template>
 
 <script setup lang="ts">
+import { computed, ref } from 'vue'
+import { uploadFile, uploadTaskPhoto, deleteTaskPhoto, type TaskPhoto } from '../../api/file'
+
+export interface UploaderPhoto {
+  id?: number | string
+  url: string
+}
+
 interface Props {
-  modelValue: string[]
+  modelValue: Array<string | UploaderPhoto>
   maxCount?: number
   tip?: string
+  /** 是否选择后立即上传 */
+  autoUpload?: boolean
+  /** taskPhoto 模式需要传入任务ID和步骤编码 */
+  taskId?: number
+  step?: string
+  /** 上传类型:file-通用文件;taskPhoto-任务照片 */
+  type?: 'file' | 'taskPhoto'
+  /** 照片分组 phase(用于 admin 前端分组互通) */
+  phase?: string
+  /** 删除时是否同步后端 */
+  syncDelete?: boolean
 }
 
 const props = withDefaults(defineProps<Props>(), {
   maxCount: 9,
   tip: '',
+  autoUpload: true,
+  type: 'file',
+  syncDelete: true,
 })
 
 const emit = defineEmits<{
-  'update:modelValue': [value: string[]]
+  'update:modelValue': [value: Array<string | UploaderPhoto>]
 }>()
 
+const uploadingSet = ref<Set<string>>(new Set())
+
+const normalizedList = computed<UploaderPhoto[]>(() => {
+  return props.modelValue.map((item) => {
+    if (typeof item === 'string') return { url: item }
+    return item
+  })
+})
+
+function emitList(list: UploaderPhoto[]) {
+  // 统一返回对象数组,保留 id 用于后端删除
+  emit('update:modelValue', list)
+}
+
 function chooseImage() {
-  const remainCount = props.maxCount - props.modelValue.length
+  const remainCount = props.maxCount - normalizedList.value.length
   if (remainCount <= 0) {
     uni.showToast({ title: '最多上传' + props.maxCount + '张', icon: 'none' })
     return
@@ -64,23 +101,73 @@ function chooseImage() {
     count: remainCount,
     sizeType: ['compressed'],
     sourceType: ['album', 'camera'],
-    success: (res) => {
-      const newImages = [...props.modelValue, ...res.tempFilePaths]
-      emit('update:modelValue', newImages)
+    success: async (res) => {
+      const tempPaths = res.tempFilePaths as string[]
+      const newItems: UploaderPhoto[] = tempPaths.map((url) => ({ url }))
+      emitList([...normalizedList.value, ...newItems])
+      if (props.autoUpload) {
+        // 串行上传,避免并发覆盖列表状态
+        let allSuccess = true
+        for (const path of tempPaths) {
+          const ok = await uploadOne(path)
+          if (!ok) allSuccess = false
+        }
+        if (allSuccess) {
+          uni.showToast({ title: '上传成功', icon: 'success' })
+        }
+      }
     },
   })
 }
 
-function removeImage(index: number) {
-  const newImages = [...props.modelValue]
-  newImages.splice(index, 1)
-  emit('update:modelValue', newImages)
+async function uploadOne(filePath: string): Promise<boolean> {
+  uploadingSet.value.add(filePath)
+  try {
+    const url =
+      props.type === 'taskPhoto' && props.taskId && props.step
+        ? await uploadTaskPhoto(filePath, props.taskId, props.step, props.phase)
+        : await uploadFile(filePath)
+
+    const current = [...normalizedList.value]
+    const idx = current.findIndex((item) => item.url === filePath)
+    if (idx >= 0) {
+      current[idx] = { ...current[idx], url }
+      emitList(current)
+      return true
+    }
+    return false
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '上传失败', icon: 'none' })
+    const current = normalizedList.value.filter((item) => item.url !== filePath)
+    emitList(current)
+    return false
+  } finally {
+    uploadingSet.value.delete(filePath)
+  }
+}
+
+async function removeImage(index: number) {
+  const item = normalizedList.value[index]
+  if (!item) return
+
+  if (props.syncDelete && item.id) {
+    try {
+      await deleteTaskPhoto(Number(item.id))
+    } catch (e: any) {
+      uni.showToast({ title: e.message || '删除失败', icon: 'none' })
+      return
+    }
+  }
+
+  const next = [...normalizedList.value]
+  next.splice(index, 1)
+  emitList(next)
 }
 
 function previewImage(index: number) {
   uni.previewImage({
-    urls: props.modelValue,
-    current: props.modelValue[index],
+    urls: normalizedList.value.map((item) => item.url),
+    current: normalizedList.value[index]?.url,
   })
 }
 </script>

+ 61 - 11
src/components/common/PhotoUploader.vue

@@ -8,7 +8,8 @@
       >
         <image
           :src="url"
-          class="w-full h-full object-cover"
+          style="width: 160rpx; height: 160rpx;"
+          class="object-cover block"
           mode="aspectFill"
           @click="previewMedia(index)"
         />
@@ -35,17 +36,29 @@
 </template>
 
 <script setup lang="ts">
+import { computed, ref } from 'vue'
+import { uploadFile, uploadTaskPhoto } from '../../api/file'
+
 const props = withDefaults(
   defineProps<{
     modelValue: string[]
     maxCount?: number
     tip?: string
     mediaType?: ('image' | 'video')[]
+    /** 是否选择后立即上传 */
+    autoUpload?: boolean
+    /** taskPhoto 模式需要传入任务ID和步骤编码 */
+    taskId?: number
+    step?: string
+    /** 上传类型:file-通用文件;taskPhoto-任务照片 */
+    type?: 'file' | 'taskPhoto'
   }>(),
   {
     maxCount: 9,
     tip: '',
-    mediaType: () => ['image', 'video']
+    mediaType: () => ['image', 'video'],
+    autoUpload: true,
+    type: 'file',
   }
 )
 
@@ -53,8 +66,14 @@ const emit = defineEmits<{
   'update:modelValue': [value: string[]]
 }>()
 
+const uploadingSet = ref<Set<string>>(new Set())
+const list = computed<string[]>({
+  get: () => props.modelValue,
+  set: (val: string[]) => emit('update:modelValue', val),
+})
+
 function chooseMedia() {
-  const remainCount = props.maxCount - props.modelValue.length
+  const remainCount = props.maxCount - list.value.length
   if (remainCount <= 0) {
     uni.showToast({ title: '最多上传' + props.maxCount + '个', icon: 'none' })
     return
@@ -64,23 +83,54 @@ function chooseMedia() {
     count: remainCount,
     mediaType: props.mediaType,
     sourceType: ['album', 'camera'],
-    success: (res) => {
-      const paths = res.tempFiles.map(f => f.tempFilePath)
-      emit('update:modelValue', [...props.modelValue, ...paths])
+    success: async (res) => {
+      const tempPaths = res.tempFiles.map((f) => f.tempFilePath)
+      list.value = [...list.value, ...tempPaths]
+      if (props.autoUpload) {
+        const results = await Promise.all(tempPaths.map((path) => uploadOne(path)))
+        if (results.every(Boolean)) {
+          uni.showToast({ title: '上传成功', icon: 'success' })
+        }
+      }
     }
   })
 }
 
+async function uploadOne(filePath: string): Promise<boolean> {
+  uploadingSet.value.add(filePath)
+  try {
+    const url =
+      props.type === 'taskPhoto' && props.taskId && props.step
+        ? await uploadTaskPhoto(filePath, props.taskId, props.step)
+        : await uploadFile(filePath)
+
+    const current = [...list.value]
+    const idx = current.indexOf(filePath)
+    if (idx >= 0) {
+      current[idx] = url
+      list.value = current
+      return true
+    }
+    return false
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '上传失败', icon: 'none' })
+    list.value = list.value.filter((u) => u !== filePath)
+    return false
+  } finally {
+    uploadingSet.value.delete(filePath)
+  }
+}
+
 function removeMedia(index: number) {
-  const list = [...props.modelValue]
-  list.splice(index, 1)
-  emit('update:modelValue', list)
+  const next = [...list.value]
+  next.splice(index, 1)
+  list.value = next
 }
 
 function previewMedia(index: number) {
   uni.previewImage({
-    urls: props.modelValue,
-    current: props.modelValue[index]
+    urls: list.value,
+    current: list.value[index]
   })
 }
 </script>

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

@@ -43,8 +43,9 @@ const bgColor = computed(() =>
 
 <style scoped>
 .StatusTag {
-  width: 88rpx;
+  min-width: 88rpx;
   padding: 0 10rpx;
   justify-content: center;
+  white-space: nowrap;
 }
 </style>

+ 14 - 6
src/components/home/ConstructionHome.vue

@@ -197,6 +197,7 @@
 import { ref, computed, onMounted } from 'vue'
 import { useAuthStore } from '@/stores/auth'
 import { useTaskStore } from '@/stores/task'
+import { useNotificationStore } from '@/stores/notification'
 import StatusBar from '@/components/common/StatusBar.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import { getNoticeList } from '@/api/notice'
@@ -204,17 +205,24 @@ import { formatDate } from '@/utils'
 
 const authStore = useAuthStore()
 const taskStore = useTaskStore()
+const notificationStore = useNotificationStore()
 
 const userName = ref(authStore.user?.name || '张师傅')
-const notificationCount = ref(1)
-const tomorrowCount = ref(2)
+const notificationCount = computed(() => notificationStore.unreadCount)
+
+const tomorrowCount = computed(() => {
+  const tomorrow = new Date()
+  tomorrow.setDate(tomorrow.getDate() + 1)
+  const tomorrowStr = formatDate(tomorrow, 'YYYY-MM-DD')
+  return taskStore.tasks.filter((t: any) => t.planDate === tomorrowStr || t.scheduleDate === tomorrowStr).length
+})
 
 const completedCount = computed(() => taskStore.tasks.filter(t => t.status === 'completed').length)
 const currentTaskCount = computed(() =>
   taskStore.tasks.filter(t => ['assigned', 'in_progress'].includes(t.status)).length
 )
 const currentTasks = computed(() =>
-  taskStore.tasks.filter(t => ['assigned', 'in_progress'].includes(t.status)).slice(0, 2)
+  taskStore.tasks.filter(t => ['assigned', 'in_progress'].includes(t.status)).slice(0, 2) as any[]
 )
 
 function taskIdOf(task: any): string {
@@ -253,7 +261,7 @@ function goToHelpCenter() {
 }
 
 function goToNotification() {
-  uni.navigateTo({ url: '/subPackages/pages-construction/notification' })
+  uni.navigateTo({ url: '/subPackages/pages-common/messageList' })
 }
 
 function goToTaskDetail(taskId: string) {
@@ -270,8 +278,8 @@ function goToNoticeDetail(noticeId: number) {
 }
 
 onMounted(async () => {
-  const today = new Date().toISOString().split('T')[0]
-  taskStore.fetchMyTasks(today)
+  taskStore.fetchMyTasks()
+  notificationStore.fetchUnreadCount()
   try {
     const res = await getNoticeList(1, 3)
     notices.value = res.rows || []

+ 5 - 2
src/components/home/DispatchHome.vue

@@ -165,14 +165,16 @@
 import { ref, computed, onMounted } from 'vue'
 import { useAuthStore } from '@/stores/auth'
 import { useTaskStore } from '@/stores/task'
+import { useNotificationStore } from '@/stores/notification'
 import StatusBar from '@/components/common/StatusBar.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 
 const authStore = useAuthStore()
 const taskStore = useTaskStore()
+const notificationStore = useNotificationStore()
 
 const userName = ref(authStore.user?.name || '调度员')
-const notificationCount = ref(2)
+const notificationCount = computed(() => notificationStore.unreadCount)
 
 const emergencyCount = computed(() => taskStore.tasks.filter(t => t.type === 'emergency').length)
 const pendingTasks = computed(() => taskStore.tasks.filter(t => ['pending', 'auditing'].includes(t.status)).slice(0, 3))
@@ -216,11 +218,12 @@ function goToEmergencyTeam() {
 }
 
 function goToNotification() {
-  uni.navigateTo({ url: '/subPackages/pages-dispatch/notification' })
+  uni.navigateTo({ url: '/subPackages/pages-common/messageList' })
 }
 
 onMounted(() => {
   taskStore.fetchTasks()
+  notificationStore.fetchUnreadCount()
 })
 </script>
 

+ 5 - 2
src/components/home/SalesHome.vue

@@ -223,6 +223,7 @@ import { ref, computed, onMounted } from 'vue'
 import { useAuthStore } from '@/stores/auth'
 import { useProjectStore } from '@/stores/project'
 import { useTaskStore } from '@/stores/task'
+import { useNotificationStore } from '@/stores/notification'
 import StatusBar from '@/components/common/StatusBar.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import { getNoticeList } from '@/api/notice'
@@ -232,9 +233,10 @@ import { formatDate } from '@/utils'
 const authStore = useAuthStore()
 const projectStore = useProjectStore()
 const taskStore = useTaskStore()
+const notificationStore = useNotificationStore()
 
 const userName = ref(authStore.user?.name || '张销售')
-const notificationCount = ref(3)
+const notificationCount = computed(() => notificationStore.unreadCount)
 
 const latestTasks = computed(() => taskStore.tasks.slice(0, 3))
 
@@ -269,7 +271,7 @@ function goToTaskReminder() {
 }
 
 function goToNotification() {
-  uni.navigateTo({ url: '/subPackages/pages-sales/notification' })
+  uni.navigateTo({ url: '/subPackages/pages-common/messageList' })
 }
 
 function goToNoticeList() {
@@ -299,6 +301,7 @@ function goToContract() {
 onMounted(async () => {
   projectStore.fetchProjects()
   taskStore.fetchTasks()
+  notificationStore.fetchUnreadCount()
   try {
     const res = await getNoticeList(1, 3)
     notices.value = res.rows || []

+ 0 - 8
src/components/my/ProfileView.vue

@@ -14,14 +14,6 @@
             <text class="name truncate">{{ displayName }}</text>
             <text class="role-chip">{{ roleLabel }}</text>
           </view>
-          <view v-if="primaryMeta || secondaryMeta" class="flex items-center flex-wrap mt-2">
-            <text v-if="primaryMeta" class="meta">{{ primaryMeta }}</text>
-            <text v-if="primaryMeta && secondaryMeta" class="meta-dot">·</text>
-            <text v-if="secondaryMeta" class="meta">{{ secondaryMeta }}</text>
-          </view>
-          <view v-if="userInfo?.employeeNo" class="mt-2">
-            <text class="emp-chip">工号 {{ userInfo.employeeNo }}</text>
-          </view>
         </view>
       </view>
     </view>

+ 9 - 13
src/components/task/ConstructionTaskCard.vue

@@ -2,7 +2,7 @@
   <view
     class="task-card p-4 clickable"
     hover-class="scale-down"
-    :class="{ 'is-emergency': task.type === 'emergency' }"
+    :class="{ 'is-emergency': (task.taskType || task.type) === 'emergency' }"
     @click="$emit('click', String((task as any).id ?? (task as any).taskId ?? ''))"
   >
     <view class="flex justify-between items-start mb-2">
@@ -64,6 +64,7 @@
 import { computed } from 'vue'
 import StatusTag from '../common/StatusTag.vue'
 import type { Task } from '@/types'
+import { SOP_STEPS } from '@/constants/sop'
 
 const props = defineProps<{
   task: Task
@@ -74,18 +75,13 @@ defineEmits<{
 }>()
 
 const steps = computed(() => {
-  const status = props.task.status
-  const list = [
-    { key: 'confirm', label: '确认', active: true },
-    { key: 'depart', label: '出车', active: ['departed', 'arrived', 'constructing', 'completed', 'inspecting'].includes(status) },
-    { key: 'arrive', label: '到达', active: ['arrived', 'constructing', 'completed', 'inspecting'].includes(status) },
-    { key: 'construct', label: '施工', active: ['constructing', 'completed', 'inspecting'].includes(status) },
-    { key: 'complete', label: '完成', active: ['completed', 'inspecting'].includes(status) },
-    { key: 'clean', label: '清洗', active: props.task.cleanTime != null || status === 'inspecting' || status === 'completed' },
-    { key: 'inspect', label: '验收', active: status === 'inspecting' || status === 'completed' },
-  ]
-  const currentIndex = list.findIndex(s => !s.active)
-  return list.map((s, i) => ({ ...s, current: i === currentIndex }))
+  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,
+  }))
 })
 </script>
 

+ 109 - 0
src/constants/sop.ts

@@ -0,0 +1,109 @@
+/**
+ * SOP 步骤统一配置
+ * 与后台 SopService / SopController 的 stepCode 对齐
+ */
+
+/** 单步配置 */
+export interface SopStepConfig {
+  /** 步骤序号 1-8 */
+  step: number
+  /** 后台 stepCode */
+  code: string
+  /** 移动端展示标题 */
+  title: string
+  /** 对应 admin 前端 PhotoPhase,用于照片分组互通 */
+  phase?: string
+  /** 是否可在该步骤上传照片 */
+  hasPhoto: boolean
+}
+
+/** 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-8 -> stepCode */
+export function getStepCode(step: number): string {
+  return SOP_STEPS.find((s) => s.step === step)?.code || ''
+}
+
+/** stepCode -> step 1-8 */
+export function getStepIndex(code: string): number {
+  return SOP_STEPS.find((s) => s.code === code)?.step || 0
+}
+
+/** stepCode -> 配置 */
+export function getStepConfig(code: string): SopStepConfig | undefined {
+  return SOP_STEPS.find((s) => s.code === code)
+}
+
+/** step 1-8 -> 配置 */
+export function getStepConfigByIndex(step: number): SopStepConfig | undefined {
+  return SOP_STEPS.find((s) => s.step === step)
+}
+
+/** 步骤中文标题列表(用于流程条展示) */
+export const SOP_STEP_TITLES = SOP_STEPS.map((s) => s.title)
+
+// ==================== 步骤业务字段配置(与 admin 前端对齐) ====================
+
+/** 出车准备检查项类型 */
+export type DepartureCheckCode = 'oil' | 'tire' | 'light' | 'hose' | 'tool'
+
+/** 出车准备检查项配置 */
+export interface DepartureCheckItem {
+  code: DepartureCheckCode
+  name: string
+  description?: string
+}
+
+/** 出车准备检查项默认配置(与 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: '检查施工工具是否齐全、完好' },
+]
+
+/** 单条检查结果 */
+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
+}

+ 7 - 1
src/manifest.json

@@ -64,7 +64,13 @@
         "usingComponents" : true,
         "optimization" : {
             "subPackages" : true
-        }
+        },
+        "permission" : {
+            "scope.userLocation" : {
+                "desc" : "你的位置信息将用于任务地址定位"
+            }
+        },
+        "requiredPrivateInfos" : [ "chooseLocation", "getLocation" ]
     },
     "mp-alipay" : {
         "usingComponents" : true

+ 54 - 23
src/stores/notification.ts

@@ -1,45 +1,76 @@
 import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
-import type { Notification } from '@/types'
-import { mockNotifications } from '@/mock'
+import type { Message } from '@/api/notice'
+import { getMessageList, markMessageRead, markAllMessageRead, getUnreadMessageCount } from '@/api/notice'
 
 export const useNotificationStore = defineStore('notification', () => {
   // State
-  const notifications = ref<Notification[]>([...mockNotifications])
-  const filterType = ref<string>('all')
+  const messages = ref<Message[]>([])
+  const unreadCount = ref(0)
+  const loading = ref(false)
 
   // Getters
-  const filteredNotifications = computed(() => {
-    if (filterType.value === 'all') return notifications.value
-    return notifications.value.filter(n => n.type === filterType.value)
-  })
-
-  const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
-  const taskUnreadCount = computed(() => notifications.value.filter(n => n.type === 'task' && !n.read).length)
+  const taskUnreadCount = computed(() => messages.value.filter(m => (m.messageType || m.type) === 'task' && (m.isRead === 0 || m.readStatus === 0)).length)
 
   // Actions
-  function setFilter(type: string) {
-    filterType.value = type
+  async function loadMessages(pageNum = 1, pageSize = 50) {
+    loading.value = true
+    try {
+      const res = await getMessageList(pageNum, pageSize)
+      messages.value = res.rows || []
+    } catch (error) {
+      console.error('加载消息列表失败', error)
+      messages.value = []
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function fetchUnreadCount() {
+    try {
+      const count = await getUnreadMessageCount()
+      unreadCount.value = count || 0
+    } catch (error) {
+      console.error('加载未读消息数失败', error)
+      unreadCount.value = 0
+    }
   }
 
-  function markAsRead(notificationId: string) {
-    const notification = notifications.value.find(n => n.id === notificationId)
-    if (notification) {
-      notification.read = true
+  async function markAsRead(messageId?: number) {
+    if (!messageId) return
+    try {
+      await markMessageRead(messageId)
+      const msg = messages.value.find(m => m.messageId === messageId)
+      if (msg) {
+        msg.isRead = 1
+        msg.readStatus = 1
+      }
+      await fetchUnreadCount()
+    } catch (error) {
+      console.error('标记已读失败', error)
     }
   }
 
-  function markAllAsRead() {
-    notifications.value.forEach(n => n.read = true)
+  async function markAllAsRead() {
+    try {
+      await markAllMessageRead()
+      messages.value.forEach(m => {
+        m.isRead = 1
+        m.readStatus = 1
+      })
+      unreadCount.value = 0
+    } catch (error) {
+      console.error('全部已读失败', error)
+    }
   }
 
   return {
-    notifications,
-    filterType,
-    filteredNotifications,
+    messages,
     unreadCount,
+    loading,
     taskUnreadCount,
-    setFilter,
+    loadMessages,
+    fetchUnreadCount,
     markAsRead,
     markAllAsRead,
   }

+ 14 - 5
src/stores/task.ts

@@ -1,7 +1,8 @@
 import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
 import type { Task, TimelineRecord } from '../types'
-import { getTaskList, getMyTasks, getTaskDetail, advanceTaskStep, getTaskLogs } from '../api/task'
+import { getTaskList, getMyTasks, getTaskDetail, executeSopStep, getTaskLogs } from '../api/task'
+import { formatDate } from '../utils'
 
 export const useTaskStore = defineStore('task', () => {
   // State
@@ -28,7 +29,12 @@ export const useTaskStore = defineStore('task', () => {
   const departedCount = computed(() => tasks.value.filter(t => t.status === 'departed').length)
   const arrivedCount = computed(() => tasks.value.filter(t => t.status === 'arrived').length)
   const inspectingCount = computed(() => tasks.value.filter(t => t.status === 'inspecting').length)
-  const tomorrowCount = ref(2)
+  const tomorrowCount = computed(() => {
+    const tomorrow = new Date()
+    tomorrow.setDate(tomorrow.getDate() + 1)
+    const tomorrowStr = formatDate(tomorrow, 'YYYY-MM-DD')
+    return tasks.value.filter((t: any) => t.planDate === tomorrowStr || t.scheduleDate === tomorrowStr).length
+  })
   const historyCount = computed(() => completedCount.value)
 
   // Actions
@@ -81,9 +87,12 @@ export const useTaskStore = defineStore('task', () => {
     currentTask.value = tasks.value.find(t => String((t as any).id ?? (t as any).taskId) === String(taskId)) || null
   }
 
-  async function advanceStep(taskId: number, step: number) {
-    await advanceTaskStep(taskId, step)
-    // 重新获取任务详情
+  async function advanceStep(taskId: number, stepCode: string) {
+    await executeSopStep({
+      taskId,
+      stepCode,
+      action: 'execute',
+    })
     await fetchTaskDetail(taskId)
   }
 

+ 53 - 45
src/subPackages/pages-common/messageDetail.vue

@@ -8,31 +8,23 @@
         <text class="notice-title">{{ message?.title || '消息详情' }}</text>
         <view class="meta-row">
           <text class="meta-text">{{ message?.createTime ? formatDate(message.createTime, 'YYYY-MM-DD HH:mm') : '' }}</text>
+          <text v-if="!isRead" class="unread-tag">未读</text>
         </view>
 
         <view class="notice-body">
           <text class="body-p">{{ message?.content || '暂无内容' }}</text>
+        </view>
+      </view>
 
-          <view v-if="message?.type === 'task'" class="task-box">
-            <view class="meta-line">
-              <text class="meta-label">任务名称</text>
-              <text class="meta-value">{{ message.title }}</text>
-            </view>
-            <view class="meta-line">
-              <text class="meta-label">执行班组</text>
-              <text class="meta-value">张师傅组</text>
-            </view>
-            <view class="meta-line">
-              <text class="meta-label">预计到达</text>
-              <text class="meta-value">09:30</text>
-            </view>
-            <view class="meta-line">
-              <text class="meta-label">联系电话</text>
-              <text class="meta-value">139****6666</text>
-            </view>
-          </view>
-
-          <text class="body-p">施工过程中如有任何问题,请及时联系调度中心。服务完成后,请及时验收确认。</text>
+      <view v-if="!isRead" class="px-4 pt-2">
+        <view
+          class="action-btn action-btn-primary"
+          hover-class="action-btn-hover"
+          :hover-start-time="0"
+          :hover-stay-time="120"
+          @click="handleMarkRead"
+        >
+          标记已读
         </view>
       </view>
     </view>
@@ -40,12 +32,26 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import TopBar from '@/components/common/TopBar.vue'
 import { formatDate } from '@/utils'
-import type { Notification } from '@/types'
+import { useNotificationStore } from '@/stores/notification'
+import type { Message } from '@/api/notice'
+
+const notificationStore = useNotificationStore()
+const message = ref<Message | null>(null)
 
-const message = ref<Notification | null>(null)
+const isRead = computed(() => {
+  if (!message.value) return true
+  return message.value.isRead === 1 || message.value.readStatus === 1
+})
+
+async function handleMarkRead() {
+  if (!message.value?.messageId) return
+  await notificationStore.markAsRead(message.value.messageId)
+  message.value.isRead = 1
+  message.value.readStatus = 1
+}
 
 onMounted(() => {
   const data = uni.getStorageSync('current_message')
@@ -87,6 +93,9 @@ onMounted(() => {
   margin-top: 8px;
   padding-bottom: 12px;
   border-bottom: 1px solid rgba(164, 216, 152, 0.18);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
 }
 
 .meta-text {
@@ -94,6 +103,15 @@ onMounted(() => {
   color: #9ca3af;
 }
 
+.unread-tag {
+  padding: 2px 10px;
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #ef4444;
+  background-color: rgba(239, 68, 68, 0.1);
+}
+
 .notice-body {
   padding-top: 12px;
 }
@@ -106,32 +124,22 @@ onMounted(() => {
   color: #4b5563;
 }
 
-.task-box {
-  padding: 12px;
-  margin-bottom: 12px;
+.action-btn {
+  width: 100%;
+  padding: 12px 0;
   border-radius: 14px;
-  background-color: rgba(164, 216, 152, 0.14);
-  border: 1px solid rgba(164, 216, 152, 0.3);
-}
-
-.meta-line {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 6px;
+  text-align: center;
+  font-size: 15px;
+  font-weight: 600;
 }
 
-.meta-line:last-child {
-  margin-bottom: 0;
+.action-btn-hover {
+  opacity: 0.88;
 }
 
-.meta-label {
-  font-size: 12px;
-  color: #9ca3af;
-}
-
-.meta-value {
-  font-size: 13px;
-  color: #4b5563;
+.action-btn-primary {
+  color: #ffffff;
+  background: linear-gradient(135deg, #368f6f 0%, #4ba98a 100%);
+  box-shadow: 0 10px 24px -10px rgba(54, 143, 111, 0.7);
 }
 </style>

+ 28 - 10
src/subPackages/pages-common/messageList.vue

@@ -21,14 +21,18 @@
         </view>
       </view>
 
-      <view v-if="filteredMessages.length === 0">
+      <view v-if="notificationStore.loading" class="py-10 text-center">
+        <text class="empty-text">加载中...</text>
+      </view>
+
+      <view v-else-if="filteredMessages.length === 0">
         <EmptyState message="暂无消息" />
       </view>
 
       <view v-else class="glass-card">
         <view
           v-for="msg in filteredMessages"
-          :key="msg.id"
+          :key="msg.messageId"
           class="list-row"
           hover-class="row-hover"
           :hover-start-time="0"
@@ -37,13 +41,13 @@
         >
           <view class="icon-chip mr-3">
             <text class="uni-icons uniui-chatbubble-filled chip-glyph"></text>
-            <view v-if="!msg.read" class="unread-dot"></view>
+            <view v-if="isUnread(msg)" class="unread-dot"></view>
           </view>
           <view class="flex-1 min-w-0 mr-2">
-            <text class="row-title truncate" :class="{ 'row-title-read': msg.read }">{{ msg.title }}</text>
+            <text class="row-title truncate" :class="{ 'row-title-read': !isUnread(msg) }">{{ msg.title }}</text>
             <text class="row-sub row-clamp">{{ msg.content }}</text>
           </view>
-          <text class="row-date flex-shrink-0">{{ formatRelativeTime(msg.createTime) }}</text>
+          <text class="row-date flex-shrink-0">{{ formatRelativeTime(msg.createTime || '') }}</text>
         </view>
       </view>
     </view>
@@ -51,11 +55,12 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import TopBar from '@/components/common/TopBar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import { useNotificationStore } from '@/stores/notification'
 import { formatRelativeTime, navigateTo } from '@/utils'
+import type { Message } from '@/api/notice'
 
 const notificationStore = useNotificationStore()
 const currentFilter = ref('all')
@@ -66,14 +71,18 @@ const filterTabs = [
   { label: '系统消息', value: 'system' },
 ]
 
+function isUnread(msg: Message) {
+  return msg.isRead === 0 || msg.readStatus === 0
+}
+
 const filteredMessages = computed(() => {
-  let list = notificationStore.notifications
+  let list = notificationStore.messages
   if (currentFilter.value !== 'all') {
-    list = list.filter(n => n.type === currentFilter.value)
+    list = list.filter(n => (n.messageType || n.type) === currentFilter.value)
   }
   return list.map(n => ({
     ...n,
-    content: n.content.length > 60 ? n.content.slice(0, 60) + '...' : n.content
+    content: n.content?.length > 60 ? n.content.slice(0, 60) + '...' : n.content,
   }))
 })
 
@@ -82,10 +91,14 @@ function markAllRead() {
 }
 
 function goToDetail(msg: typeof filteredMessages.value[0]) {
-  notificationStore.markAsRead(msg.id)
+  notificationStore.markAsRead(msg.messageId)
   uni.setStorageSync('current_message', msg)
   navigateTo('/subPackages/pages-common/messageDetail')
 }
+
+onMounted(() => {
+  notificationStore.loadMessages()
+})
 </script>
 
 <style scoped>
@@ -210,4 +223,9 @@ function goToDetail(msg: typeof filteredMessages.value[0]) {
   font-size: 12px;
   color: #9ca3af;
 }
+
+.empty-text {
+  font-size: 13px;
+  color: #9ca3af;
+}
 </style>

Dosya farkı çok büyük olduğundan ihmal edildi
+ 544 - 245
src/subPackages/pages-common/publishTask.vue


+ 55 - 1
src/subPackages/pages-common/taskDetail.vue

@@ -15,6 +15,10 @@
             <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>
             </view>
           </view>
           <text class="meta-text flex-shrink-0">{{ task.taskNo }}</text>
@@ -114,17 +118,19 @@
 import { ref, computed } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 import { useTaskStore } from '@/stores/task'
+import { useAuthStore } from '@/stores/auth'
 import TopBar from '@/components/common/TopBar.vue'
 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 } from '@/api/task'
+import { getTaskDetail, urgeTask } from '@/api/task'
 import { getProjectDetail } from '@/api/project'
 import { formatDate } from '@/utils'
 import type { ServicePoint } from '@/types'
 
 const taskStore = useTaskStore()
+const authStore = useAuthStore()
 
 const task = ref<any>(null)
 const project = ref<any>(null)
@@ -181,6 +187,10 @@ const customerPhone = computed(() => {
   return project.value?.customerPhone || extra.value.customerPhone || project.value?.primaryContactPhone || ''
 })
 
+const canUrge = computed(() => {
+  return task.value && !['completed', 'cancelled'].includes(task.value.status)
+})
+
 const servicePoints = computed<ServicePoint[]>(() => {
   const raw = project.value?.servicePoints
   if (!raw) return []
@@ -218,6 +228,31 @@ function callPhone(phone: string) {
   uni.makePhoneCall({ phoneNumber: phone })
 }
 
+async function handleUrge() {
+  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
+  try {
+    await urgeTask(task.value.taskId, res.content || '用户催单',
+      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)
+  }
+}
+
 onLoad((options) => {
   if (options?.id) {
     loadDetail(Number(options.id))
@@ -395,6 +430,25 @@ function loadProject(projectId: number | string | undefined) {
   color: #368f6f;
 }
 
+.urge-btn {
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 999px;
+  background-color: rgba(239, 68, 68, 0.1);
+}
+
+.urge-icon {
+  font-size: 13px;
+  color: #ef4444;
+}
+
+.urge-text {
+  font-size: 12px;
+  font-weight: 600;
+  color: #ef4444;
+}
+
 .empty-text {
   font-size: 13px;
   color: #9ca3af;

+ 373 - 40
src/subPackages/pages-construction/taskDetail.vue

@@ -67,23 +67,128 @@
         </view>
       </view>
 
-      <!-- 出车准备(SOP:推进到第2步前) -->
-      <view v-if="currentStep === 1" class="glass-card p-4">
-        <text class="text-base font-semibold text-gray-800 mb-4 block">出车准备(SOP)</text>
-        <view class="gap-y-3 mb-4">
-          <view
-            v-for="item in checklist"
-            :key="item.key"
-            class="flex items-center justify-between py-2 border-b border-gray-100"
-            @click="item.checked = !item.checked"
-          >
-            <text class="text-sm text-gray-700">{{ item.label }}</text>
-            <view class="check-box" :class="item.checked ? 'check-box-on' : ''">
-              <text v-if="item.checked" class="uni-icons uniui-checkmarkempty check-icon"></text>
+      <!-- 步骤业务数据表单(与 admin 前端字段对齐) -->
+      <view v-if="canAdvance" class="glass-card p-4">
+        <view v-if="nextStepCode === 'prepare'">
+          <text class="text-base font-semibold text-gray-800 mb-4 block">出车准备 - 车辆点检</text>
+          <view class="gap-y-3 mb-4">
+            <view
+              v-for="item in DEFAULT_DEPARTURE_CHECKS"
+              :key="item.code"
+              class="py-3 border-b border-gray-100"
+            >
+              <view class="flex items-center justify-between mb-2">
+                <view>
+                  <text class="text-sm font-medium text-gray-700">{{ item.name }}</text>
+                  <text class="text-xs text-gray-400 block">{{ item.description }}</text>
+                </view>
+                <view
+                  class="check-box"
+                  :class="departureChecks[item.code]?.passed ? 'check-box-on' : ''"
+                  @click="toggleDepartureCheck(item.code)"
+                >
+                  <text v-if="departureChecks[item.code]?.passed" class="uni-icons uniui-checkmarkempty check-icon"></text>
+                </view>
+              </view>
+              <textarea
+                v-model="departureChecks[item.code].remark"
+                class="form-textarea text-xs"
+                style="min-height: 48px;"
+                placeholder="备注(如有异常请填写)"
+              />
             </view>
           </view>
+          <text class="text-xs text-gray-400 block">请完成全部车辆点检后方可出发</text>
+        </view>
+
+        <view v-else-if="nextStepCode === 'arrive'">
+          <text class="text-base font-semibold text-gray-800 mb-4 block">现场踏勘</text>
+          <view class="gap-y-4">
+            <view class="py-2 border-b border-gray-100">
+              <text class="text-sm text-gray-700 mb-2 block">对接人是否已到位</text>
+              <view class="flex gap-4">
+                <view
+                  class="flex items-center"
+                  @click="siteSurvey.contactStatus = 'arrived'"
+                >
+                  <view class="radio-circle" :class="siteSurvey.contactStatus === 'arrived' ? 'radio-circle-on' : ''"></view>
+                  <text class="text-sm ml-2">已到位</text>
+                </view>
+                <view
+                  class="flex items-center"
+                  @click="siteSurvey.contactStatus = 'not_arrived'"
+                >
+                  <view class="radio-circle" :class="siteSurvey.contactStatus === 'not_arrived' ? 'radio-circle-on' : ''"></view>
+                  <text class="text-sm ml-2">未到位</text>
+                </view>
+              </view>
+              <textarea
+                v-if="siteSurvey.contactStatus === 'not_arrived'"
+                v-model="siteSurvey.notArrivedRemark"
+                class="form-textarea text-xs mt-2"
+                style="min-height: 48px;"
+                placeholder="请说明未到位情况及处理方式"
+              />
+            </view>
+
+            <view class="py-2 border-b border-gray-100">
+              <text class="text-sm text-gray-700 mb-2 block">现场情况与工单描述是否一致</text>
+              <view class="flex gap-4">
+                <view
+                  class="flex items-center"
+                  @click="siteSurvey.consistencyStatus = 'consistent'"
+                >
+                  <view class="radio-circle" :class="siteSurvey.consistencyStatus === 'consistent' ? 'radio-circle-on' : ''"></view>
+                  <text class="text-sm ml-2">一致</text>
+                </view>
+                <view
+                  class="flex items-center"
+                  @click="siteSurvey.consistencyStatus = 'inconsistent'"
+                >
+                  <view class="radio-circle" :class="siteSurvey.consistencyStatus === 'inconsistent' ? 'radio-circle-on' : ''"></view>
+                  <text class="text-sm ml-2">不一致</text>
+                </view>
+              </view>
+              <textarea
+                v-if="siteSurvey.consistencyStatus === 'inconsistent'"
+                v-model="siteSurvey.inconsistencyRemark"
+                class="form-textarea text-xs mt-2"
+                style="min-height: 48px;"
+                placeholder="请说明不一致的具体情况及处理建议"
+              />
+            </view>
+
+            <view class="py-2">
+              <text class="text-sm text-gray-700 mb-2 block">客户是否同意施工</text>
+              <view
+                class="switch-wrap"
+                :class="siteSurvey.customerAgreed ? 'switch-wrap-on' : ''"
+                @click="siteSurvey.customerAgreed = !siteSurvey.customerAgreed"
+              >
+                <view class="switch-dot" :class="siteSurvey.customerAgreed ? 'switch-dot-on' : ''"></view>
+                <text class="text-sm ml-2">{{ siteSurvey.customerAgreed ? '已同意' : '未同意' }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <view v-else-if="nextStepCode === 'work'">
+          <text class="text-base font-semibold text-gray-800 mb-4 block">堵塞物情况</text>
+          <textarea
+            v-model="blockageText"
+            class="form-textarea"
+            placeholder="请描述堵塞物情况"
+          />
+        </view>
+
+        <view v-else-if="nextStepCode === 'photo'">
+          <text class="text-base font-semibold text-gray-800 mb-4 block">设施反馈</text>
+          <textarea
+            v-model="feedbackText"
+            class="form-textarea"
+            placeholder="请描述设施反馈情况"
+          />
         </view>
-        <text class="text-xs text-gray-400 block">请完成全部车辆点检后方可出发</text>
       </view>
 
       <!-- SOP 推进操作区 -->
@@ -94,7 +199,15 @@
         </view>
         <view class="mb-4">
           <text class="text-sm text-gray-500 mb-2 block">现场照片(可选)</text>
-          <ImageUploader v-model="stepPhotos" :max-count="6" tip="上传本步骤现场照片" />
+          <ImageUploader
+            v-model="stepPhotos"
+            :max-count="6"
+            type="taskPhoto"
+            :task-id="taskId"
+            :step="currentStepCode"
+            :phase="currentStepConfig?.phase"
+            tip="上传本步骤现场照片"
+          />
         </view>
         <view class="mb-4">
           <text class="text-sm text-gray-500 mb-2 block">备注(可选)</text>
@@ -357,23 +470,54 @@ import StatusTag from '../../components/common/StatusTag.vue'
 import EmptyState from '../../components/common/EmptyState.vue'
 import ImageUploader from '../../components/common/ImageUploader.vue'
 import { formatDate, showToast } from '../../utils'
-import { getTaskDetail, advanceTaskStep, updateTask } from '../../api/task'
+import { useAuthStore } from '../../stores/auth'
+import { getTaskDetail, executeSopStep, completeTask, updateTask, getSopSteps, type ExecuteSopStepData } from '../../api/task'
+import { getTaskPhotos, type TaskPhoto } from '../../api/file'
+import type { UploaderPhoto } from '../../components/common/ImageUploader.vue'
 import { getProjectDetail } from '../../api/project'
 import { getTeamSimpleList } from '../../api/team'
 import { getVehicleSimpleList } from '../../api/vehicle'
-
-const STEP_LABELS = ['确认', '准备', '现场', '施工', '拍照', '清洗', '签字', '清理']
-const stepLabels = STEP_LABELS
+import {
+  SOP_STEPS,
+  SOP_STEP_TITLES,
+  getStepCode,
+  getStepConfigByIndex,
+  DEFAULT_DEPARTURE_CHECKS,
+  type DepartureCheckResult,
+  type SiteSurveyResult,
+} from '../../constants/sop'
+
+const stepLabels = SOP_STEP_TITLES
 
 const task = ref<any>(null)
+const authStore = useAuthStore()
 const project = ref<any>(null)
 const loading = ref(false)
 const submitting = ref(false)
 const taskId = ref<number>(0)
 
-const stepPhotos = ref<string[]>([])
+const stepPhotos = ref<UploaderPhoto[]>([])
+const allPhotos = ref<TaskPhoto[]>([])
 const stepRemark = ref('')
 
+// 各步骤业务数据(与 admin 前端对齐)
+const departureChecks = ref<Record<string, DepartureCheckResult>>({
+  oil: { code: 'oil', passed: false, remark: '' },
+  tire: { code: 'tire', passed: false, remark: '' },
+  light: { code: 'light', passed: false, remark: '' },
+  hose: { code: 'hose', passed: false, remark: '' },
+  tool: { code: 'tool', passed: false, remark: '' },
+})
+const siteSurvey = ref<SiteSurveyResult>({
+  contactStatus: undefined,
+  consistencyStatus: undefined,
+  customerAgreed: false,
+  notArrivedRemark: '',
+  inconsistencyRemark: '',
+})
+const blockageText = ref('')
+const feedbackText = ref('')
+
 const checklist = reactive([
   { key: 'oil', label: '机油检查', checked: false },
   { key: 'tire', label: '轮胎检查', checked: false },
@@ -391,6 +535,9 @@ const teamOptions = ref<Array<{ value: number; label: string }>>([])
 const vehicleOptions = ref<Array<{ value: number; label: string }>>([])
 
 const currentStep = computed(() => Number(task.value?.currentStep) || 0)
+const currentStepCode = computed(() => getStepCode(currentStep.value + 1))
+const currentStepConfig = computed(() => getStepConfigByIndex(currentStep.value + 1))
+const nextStepCode = computed(() => currentStepCode.value)
 const taskLogs = computed(() => task.value?.logList || [])
 
 const isPendingConfirm = computed(() => task.value?.status === 'assigned' && currentStep.value === 0)
@@ -402,20 +549,33 @@ const canAdvance = computed(() => {
   return currentStep.value < 8
 })
 
-const advanceDisabled = computed(() => currentStep.value === 1 && !checklist.every(c => c.checked))
+const advanceDisabled = computed(() => {
+  const code = nextStepCode.value
+  // 出车准备步骤必须全部点检通过
+  if (code === 'prepare') {
+    return !DEFAULT_DEPARTURE_CHECKS.every((item) => departureChecks.value[item.code]?.passed)
+  }
+  // 到达现场步骤必须完成踏勘且客户同意
+  if (code === 'arrive') {
+    const s = siteSurvey.value
+    return !s.contactStatus || !s.consistencyStatus || !s.customerAgreed
+  }
+  return false
+})
 
 const nextActionText = computed(() => {
-  const next = currentStep.value + 1
-  const map: Record<number, string> = {
-    2: '完成准备,出发',
-    3: '到达现场',
-    4: '开始施工',
-    5: '完成拍照记录',
-    6: '完成清洗',
-    7: '完成签字验收',
-    8: '完成清理,结束任务',
+  const nextConfig = getStepConfigByIndex(currentStep.value + 1)
+  if (!nextConfig) return '推进到下一步'
+  const actionMap: Record<string, string> = {
+    prepare: '完成准备,出发',
+    arrive: '到达现场',
+    work: '开始施工',
+    photo: '完成拍照记录',
+    clean: '完成清洗',
+    accept: '完成签字验收',
+    finish: '完成清理,结束任务',
   }
-  return map[next] || '推进到下一步'
+  return actionMap[nextConfig.code] || `推进到「${nextConfig.title}」`
 })
 
 const extra = computed(() => {
@@ -548,6 +708,7 @@ async function loadDetail() {
     if (vehicleOptions.value.length === 0) {
       getVehicleSimpleList().then(list => { vehicleOptions.value = list }).catch(() => {})
     }
+    await loadPhotos()
   } catch (e) {
     console.error('加载任务详情失败', e)
     showToast('加载任务详情失败')
@@ -556,16 +717,107 @@ async function loadDetail() {
   }
 }
 
-async function doAdvance(step: number, successMsg: string) {
+async function loadPhotos() {
+  try {
+    allPhotos.value = await getTaskPhotos(taskId.value)
+    filterStepPhotos()
+  } catch (e) {
+    console.error('加载任务照片失败', e)
+  }
+}
+
+function filterStepPhotos() {
+  const phase = currentStepConfig.value?.phase
+  if (!phase) {
+    stepPhotos.value = []
+    return
+  }
+  stepPhotos.value = allPhotos.value
+    .filter((p) => p.phase === phase || p.stepCode === currentStepCode.value)
+    .map((p) => ({ id: p.photoId, url: p.photoUrl || '' }))
+    .filter((p) => p.url)
+}
+
+/** 判断是否为本地/临时文件路径(仍在上传中) */
+function isTempUrl(url: string): boolean {
+  if (!url) return true
+  // H5 blob/data、小程序 wxfile、APP 沙盒路径、部分环境的 http://tmp/ 临时文件
+  return /^(blob|data|file|wxfile|_doc|_downloads|_user|http:\/\/tmp\/)/i.test(url)
+}
+
+function toggleDepartureCheck(code: string) {
+  const item = departureChecks.value[code]
+  if (item) {
+    item.passed = !item.passed
+  }
+}
+
+function buildStepData(): string | undefined {
+  const code = nextStepCode.value
+  if (code === 'prepare') {
+    const results = DEFAULT_DEPARTURE_CHECKS.map((item) => departureChecks.value[item.code]).filter(Boolean)
+    if (results.length === 0) return undefined
+    // 与 admin 日志格式保持一致:{ results: CheckItemResult[] }
+    return JSON.stringify({ results })
+  }
+  if (code === 'arrive') {
+    if (!siteSurvey.value.contactStatus && !siteSurvey.value.consistencyStatus) return undefined
+    // 与 admin 日志格式保持一致:直接存储 SiteSurveyResult
+    return JSON.stringify(siteSurvey.value)
+  }
+  if (code === 'work' && blockageText.value.trim()) {
+    return JSON.stringify({ blockageText: blockageText.value.trim() })
+  }
+  if (code === 'photo' && feedbackText.value.trim()) {
+    return JSON.stringify({ feedbackText: feedbackText.value.trim() })
+  }
+  return undefined
+}
+
+function clearStepForm() {
+  departureChecks.value = {
+    oil: { code: 'oil', passed: false, remark: '' },
+    tire: { code: 'tire', passed: false, remark: '' },
+    light: { code: 'light', passed: false, remark: '' },
+    hose: { code: 'hose', passed: false, remark: '' },
+    tool: { code: 'tool', passed: false, remark: '' },
+  }
+  siteSurvey.value = {
+    contactStatus: undefined,
+    consistencyStatus: undefined,
+    customerAgreed: false,
+    notArrivedRemark: '',
+    inconsistencyRemark: '',
+  }
+  blockageText.value = ''
+  feedbackText.value = ''
+}
+
+async function doAdvance(stepCode: string, successMsg: string) {
+  if (stepPhotos.value.some((p) => isTempUrl(typeof p === 'string' ? p : p.url))) {
+    showToast('照片正在上传,请稍候')
+    return
+  }
   submitting.value = true
   try {
-    const data: Record<string, any> = {}
+    const data: ExecuteSopStepData = {
+      taskId: taskId.value,
+      stepCode,
+      action: 'execute',
+      operatorId: authStore.user?.id ? Number(authStore.user.id) : undefined,
+      operatorName: authStore.user?.name,
+    }
     if (stepRemark.value.trim()) data.remark = stepRemark.value.trim()
-    if (stepPhotos.value.length > 0) data.photos = stepPhotos.value
-    await advanceTaskStep(taskId.value, step, Object.keys(data).length > 0 ? data : undefined)
+    const photos = stepPhotos.value.map((p) => (typeof p === 'string' ? p : p.url)).filter(Boolean)
+    if (photos.length > 0) data.photos = photos
+    const stepData = buildStepData()
+    if (stepData) data.data = stepData
+
+    await executeSopStep(data)
     showToast(successMsg)
     stepPhotos.value = []
     stepRemark.value = ''
+    clearStepForm()
     await loadDetail()
   } catch (e) {
     console.error('推进SOP步骤失败', e)
@@ -575,17 +827,48 @@ async function doAdvance(step: number, successMsg: string) {
 }
 
 function onConfirm() {
-  doAdvance(1, '任务已确认')
+  doAdvance('confirm', '任务已确认')
+}
+
+async function doComplete() {
+  submitting.value = true
+  try {
+    await completeTask(taskId.value, {
+      taskId: taskId.value,
+      remark: stepRemark.value.trim() || undefined,
+      operatorId: authStore.user?.id ? Number(authStore.user.id) : undefined,
+      operatorName: authStore.user?.name,
+    })
+    showToast('任务已完成')
+    stepPhotos.value = []
+    stepRemark.value = ''
+    await loadDetail()
+  } catch (e) {
+    console.error('完成任务失败', e)
+  } finally {
+    submitting.value = false
+  }
 }
 
 function onAdvance() {
   if (advanceDisabled.value) {
-    showToast('请先完成全部出车点检')
+    const code = nextStepCode.value
+    if (code === 'prepare') {
+      showToast('请先完成全部出车点检')
+    } else if (code === 'arrive') {
+      showToast('请完成现场踏勘并确认客户同意施工')
+    } else {
+      showToast('请完成当前步骤必填信息')
+    }
     return
   }
-  const next = currentStep.value + 1
-  if (next > 8) return
-  doAdvance(next, next === 8 ? '任务已完成' : `已推进到「${STEP_LABELS[next - 1]}」`)
+  const nextConfig = getStepConfigByIndex(currentStep.value + 1)
+  if (!nextConfig) return
+  if (nextConfig.step === 8) {
+    doComplete()
+  } else {
+    doAdvance(nextConfig.code, `已推进到「${nextConfig.title}」`)
+  }
 }
 
 async function onReject() {
@@ -914,4 +1197,54 @@ function onFeedback() {
   background: #fff;
   min-height: 72px;
 }
+
+/* 单选按钮 */
+.radio-circle {
+  width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  border: 2px solid #d1d5db;
+  position: relative;
+  transition: all 0.2s ease;
+}
+.radio-circle-on {
+  border-color: #368f6f;
+  background-color: #368f6f;
+}
+.radio-circle-on::after {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 8px;
+  height: 8px;
+  background-color: #fff;
+  border-radius: 50%;
+  transform: translate(-50%, -50%);
+}
+
+/* 开关 */
+.switch-wrap {
+  display: inline-flex;
+  align-items: center;
+  padding: 6px 12px 6px 6px;
+  border-radius: 9999px;
+  background-color: #f3f4f6;
+  border: 1px solid #e5e7eb;
+  transition: all 0.2s ease;
+}
+.switch-wrap-on {
+  background-color: rgba(54, 143, 111, 0.12);
+  border-color: #368f6f;
+}
+.switch-dot {
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background-color: #d1d5db;
+  transition: all 0.2s ease;
+}
+.switch-dot-on {
+  background-color: #368f6f;
+}
 </style>

+ 2 - 1
src/subPackages/pages-construction/tomorrowTask.vue

@@ -41,13 +41,14 @@ import { computed, onMounted } from 'vue'
 import { useTaskStore } from '../../stores/task'
 import TopBar from '../../components/common/TopBar.vue'
 import EmptyState from '../../components/common/EmptyState.vue'
+import { formatDate } from '../../utils'
 
 const taskStore = useTaskStore()
 
 const tomorrowTasks = computed<any[]>(() => {
   const tomorrow = new Date()
   tomorrow.setDate(tomorrow.getDate() + 1)
-  const tomorrowStr = tomorrow.toISOString().split('T')[0]
+  const tomorrowStr = formatDate(tomorrow, 'YYYY-MM-DD')
   return taskStore.tasks.filter((t: any) => t.planDate === tomorrowStr || t.scheduleDate === tomorrowStr)
 })
 

+ 12 - 2
src/subPackages/pages-contract/contractForm.vue

@@ -23,6 +23,12 @@
             <text v-if="errors.contractNo" class="text-red-500 text-xs mt-1">{{ errors.contractNo }}</text>
           </view>
 
+          <view>
+            <text class="form-label">合同名称<text class="text-red-500">*</text></text>
+            <uni-easyinput class="form-input" placeholder="请输入合同名称" v-model="form.contractName" @blur="validateField('contractName')" />
+            <text v-if="errors.contractName" class="text-red-500 text-xs mt-1">{{ errors.contractName }}</text>
+          </view>
+
           <view>
             <text class="form-label">关联客户<text class="text-red-500">*</text></text>
             <picker mode="selector" :range="customerLabels" :value="customerIndex" @change="onCustomerChange">
@@ -170,6 +176,7 @@ const customerLabels = computed(() => customers.value.map(c => c.customerName))
 
 const form = ref({
   contractNo: '',
+  contractName: '',
   customerId: '' as number | string,
   customerName: '',
   contractAmount: '' as number | string,
@@ -183,6 +190,7 @@ const form = ref({
 
 const errors = reactive<Record<string, string>>({
   contractNo: '',
+  contractName: '',
   customerId: '',
   contractAmount: ''
 })
@@ -241,7 +249,7 @@ function validateField(field: string) {
   }
   const value = (form.value as any)[field]
   if (value === undefined || value === null || value === '') {
-    const names: Record<string, string> = { contractNo: '合同编号', contractAmount: '合同金额' }
+    const names: Record<string, string> = { contractNo: '合同编号', contractName: '合同名称', contractAmount: '合同金额' }
     errors[field] = `请输入${names[field] || field}`
     return false
   }
@@ -257,7 +265,7 @@ function validateField(field: string) {
 
 function validateForm() {
   let ok = true
-  for (const f of ['contractNo', 'customerId', 'contractAmount']) {
+  for (const f of ['contractNo', 'contractName', 'customerId', 'contractAmount']) {
     if (!validateField(f)) ok = false
   }
   return ok
@@ -285,6 +293,7 @@ async function loadDetail(id: number | string) {
     const sp = parseServicePeriod(c.servicePeriod)
     form.value = {
       contractNo: c.contractNo || '',
+      contractName: c.contractName || '',
       customerId: c.customerId || '',
       customerName: c.customerName || '',
       contractAmount: c.contractAmount ?? '',
@@ -315,6 +324,7 @@ async function submitForm() {
         : undefined
     const payload = {
       contractNo: form.value.contractNo.trim(),
+      contractName: form.value.contractName.trim() || undefined,
       customerId: form.value.customerId,
       customerName: form.value.customerName || undefined,
       contractAmount: Number(form.value.contractAmount),

Dosya farkı çok büyük olduğundan ihmal edildi
+ 771 - 119
src/subPackages/pages-dispatch/publishTask.vue


+ 53 - 1
src/subPackages/pages-dispatch/taskDetail.vue

@@ -13,6 +13,10 @@
           <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>
+            <text class="urge-text">催单</text>
+          </view>
         </view>
         <view class="gap-y-3">
           <view class="flex items-start">
@@ -335,7 +339,7 @@ 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 { getTaskDetail, approveTask, rejectTaskAudit, dispatchTask } from '../../api/task'
+import { getTaskDetail, approveTask, rejectTaskAudit, dispatchTask, urgeTask } from '../../api/task'
 import { getProjectDetail } from '../../api/project'
 import { getTeamSimpleList } from '../../api/team'
 import { getVehicleSimpleList } from '../../api/vehicle'
@@ -373,6 +377,7 @@ const taskLogs = computed(() => task.value?.logList || [])
 
 const isPendingAudit = computed(() => ['auditing', 'pending'].includes(task.value?.status))
 const isDispatched = computed(() => ['assigned', 'in_progress', 'completed'].includes(task.value?.status))
+const canUrge = computed(() => task.value && !['completed', 'cancelled'].includes(task.value.status))
 
 const taskName = computed(() => {
   if (!task.value) return ''
@@ -582,6 +587,34 @@ async function arrangeTeam() {
     submitting.value = false
   }
 }
+
+async function handleUrge() {
+  if (!taskId.value) 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
+  try {
+    await urgeTask(
+      taskId.value,
+      res.content || '用户催单',
+      authStore.userInfo?.id ? Number(authStore.userInfo.id) : 0,
+      authStore.userInfo?.name || ''
+    )
+    uni.showToast({ title: '催单成功', icon: 'success' })
+    loadDetail()
+  } catch (error) {
+    console.error('催单失败', error)
+  }
+}
 </script>
 
 <style scoped>
@@ -677,6 +710,25 @@ async function arrangeTeam() {
   background-color: #f3f4f6;
 }
 
+.urge-btn {
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 999px;
+  background-color: rgba(239, 68, 68, 0.1);
+}
+
+.urge-icon {
+  font-size: 13px;
+  color: #ef4444;
+}
+
+.urge-text {
+  font-size: 12px;
+  font-weight: 600;
+  color: #ef4444;
+}
+
 .phone-circle {
   width: 32px;
   height: 32px;

+ 41 - 8
src/types/index.ts

@@ -151,17 +151,23 @@ export interface Contract {
 
 export type TaskType = 'regular' | 'emergency'
 export type TaskStatus =
+  // 后台标准状态
   | 'pending'
+  | 'auditing'
+  | 'approved'
+  | 'assigned'
+  | 'in_progress'
+  | 'completed'
+  | 'cancelled'
+  | 'rejected'
+  // 历史/兼容状态(逐步迁移到后台标准)
   | 'confirmed'
   | 'scheduled'
   | 'departed'
   | 'arrived'
   | 'constructing'
   | 'inspecting'
-  | 'completed'
-  | 'cancelled'
-  | 'rejected'
-export type UrgentLevel = 'high' | 'normal'
+export type UrgentLevel = 'high' | 'normal' | 'very_urgent' | 'urgent'
 
 export interface TimelineRecord {
   time: string
@@ -171,18 +177,31 @@ export interface TimelineRecord {
 }
 
 export interface Task {
-  id: string
+  /** 后台 taskId(优先使用) */
+  taskId?: string | number
+  /** 兼容字段,等同于 taskId */
+  id?: string
+  taskNo?: string
+  taskName?: string
   projectId: string
   projectName: string
   coopType?: string
-  type: TaskType
+  /** 后台 taskType */
+  taskType?: string
+  /** 兼容字段,等同于 taskType */
+  type?: TaskType
   status: TaskStatus
-  urgent: UrgentLevel
+  /** 后台 urgencyLevel */
+  urgencyLevel?: string
+  /** 兼容字段 */
+  urgent?: UrgentLevel
   serviceType?: string
   facilityType?: string
   serviceMode?: string
   faultLocation?: string
+  faultType?: string
   faultDesc?: string
+  customerWords?: string
   address?: string
   geoLocation?: string
   vehicle?: string
@@ -204,6 +223,8 @@ export interface Task {
   previousConstruction?: string
   customerName?: string
   customerPhone?: string
+  primaryContactName?: string
+  primaryContactPhone?: string
   partyAContacts?: { name: string; phone: string }[]
   contacts?: { name: string; phone: string; role?: string }[]
   scheduledDate?: string
@@ -211,6 +232,16 @@ export interface Task {
   scheduleTime?: string
   serviceDate?: string
   serviceTime?: string
+  planDate?: string
+  planTimeRange?: string
+  /** SOP 当前步骤 0-8 */
+  currentStep?: number
+  /** SOP 状态描述 */
+  sopStatus?: string
+  departureTime?: string
+  arrivalTime?: string
+  completionTime?: string
+  /** 兼容字段,分别对应 departureTime / arrivalTime / completionTime */
   departTime?: string
   arriveTime?: string
   constructTime?: string
@@ -218,7 +249,9 @@ export interface Task {
   cleanTime?: string
   inspectTime?: string
   createTime: string
-  timeline: TimelineRecord[]
+  timeline?: TimelineRecord[]
+  /** 后台操作日志 */
+  logList?: any[]
   location?: { lat: number; lng: number }
   mediaList?: { type: 'image' | 'video'; url: string }[]
 }

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