瀏覽代碼

feat: 完善首页统计、任务、知识库及调度模块功能

mcc 1 天之前
父節點
當前提交
2b2d3eda18
共有 41 個文件被更改,包括 2479 次插入880 次删除
  1. 0 0
      JS_EOF
  2. 0 0
      construction-login.json
  3. 0 0
      dispatch-login.json
  4. 0 0
      sales-login.json
  5. 28 0
      scripts/test-ping.js
  6. 224 0
      scripts/verify-construction-feedback.js
  7. 111 0
      scripts/verify-home-stats.js
  8. 197 0
      scripts/verify-notifications.js
  9. 0 0
      scripts/write-plan.js
  10. 21 3
      src/App.vue
  11. 2 1
      src/api/knowledge.ts
  12. 1 1
      src/api/schedule.ts
  13. 12 0
      src/api/task.ts
  14. 232 87
      src/components/common/ChartView.vue
  15. 104 70
      src/components/home/ConstructionHome.vue
  16. 182 13
      src/components/home/DispatchHome.vue
  17. 79 25
      src/components/home/SalesHome.vue
  18. 2 2
      src/components/my/ProfileView.vue
  19. 3 3
      src/components/task/ConstructionTaskCard.vue
  20. 40 23
      src/components/task/TaskListView.vue
  21. 1 1
      src/config/index.ts
  22. 1 1
      src/pages/home/home.vue
  23. 1 1
      src/pages/my/my.vue
  24. 1 1
      src/pages/schedule/schedule.vue
  25. 1 1
      src/pages/task/task.vue
  26. 10 0
      src/stores/auth.ts
  27. 51 0
      src/stores/notification.ts
  28. 6 4
      src/stores/task.ts
  29. 202 81
      src/subPackages/pages-common/knowledgeDetail.vue
  30. 32 64
      src/subPackages/pages-common/knowledgeList.vue
  31. 19 6
      src/subPackages/pages-common/publishTask.vue
  32. 115 131
      src/subPackages/pages-common/taskDetail.vue
  33. 77 27
      src/subPackages/pages-construction/taskDetail.vue
  34. 113 51
      src/subPackages/pages-dispatch/taskDetail.vue
  35. 32 99
      src/subPackages/pages-dispatch/vehicleList.vue
  36. 77 22
      src/subPackages/pages-dispatch/visualization.vue
  37. 204 99
      src/subPackages/pages-sales/knowledgeDetail.vue
  38. 35 60
      src/subPackages/pages-sales/knowledgeList.vue
  39. 58 3
      src/utils/index.ts
  40. 205 0
      src/utils/websocket.ts
  41. 0 0
      users.json

+ 0 - 0
JS_EOF


文件差異過大導致無法顯示
+ 0 - 0
construction-login.json


文件差異過大導致無法顯示
+ 0 - 0
dispatch-login.json


文件差異過大導致無法顯示
+ 0 - 0
sales-login.json


+ 28 - 0
scripts/test-ping.js

@@ -0,0 +1,28 @@
+const WebSocket = require('ws')
+
+const WS_URL = 'ws://localhost:8080/api/ws/notify'
+const token = 'mock-token-mp-21'
+
+const ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(token)}`)
+
+ws.on('open', () => {
+  console.log('Connected')
+  ws.send(JSON.stringify({ type: 'ping' }))
+})
+
+ws.on('message', (data) => {
+  console.log('Received:', data.toString())
+})
+
+ws.on('error', (err) => {
+  console.error('Error:', err.message)
+})
+
+ws.on('close', () => {
+  console.log('Closed')
+})
+
+setTimeout(() => {
+  ws.close()
+  process.exit(0)
+}, 5000)

+ 224 - 0
scripts/verify-construction-feedback.js

@@ -0,0 +1,224 @@
+const WebSocket = require('ws')
+const http = require('http')
+
+const BASE_URL = 'http://localhost:8080/api'
+const WS_URL = 'ws://localhost:8080/api/ws/notify'
+
+const TOKENS = {
+  sales: 'mock-token-mp-21',
+  dispatch: 'mock-token-mp-13',
+  construction: 'mock-token-mp-15',
+}
+
+const ROLES = {
+  sales: { userId: 21, name: '徐云辉' },
+  dispatch: { userId: 13, name: '张智城' },
+  construction: { userId: 15, name: '测试实施1' },
+}
+
+function request(method, path, data, token) {
+  return new Promise((resolve, reject) => {
+    const options = {
+      hostname: 'localhost',
+      port: 8080,
+      path: '/api' + path,
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`,
+      },
+    }
+    const req = http.request(options, (res) => {
+      let body = ''
+      res.on('data', (chunk) => (body += chunk))
+      res.on('end', () => {
+        try {
+          resolve(JSON.parse(body))
+        } catch (e) {
+          resolve(body)
+        }
+      })
+    })
+    req.on('error', reject)
+    if (data) req.write(JSON.stringify(data))
+    req.end()
+  })
+}
+
+function connectWebSocket(role) {
+  return new Promise((resolve, reject) => {
+    const ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(TOKENS[role])}`)
+    const messages = []
+    ws.on('open', () => {
+      console.log(`[${role}] WebSocket connected`)
+      resolve({ ws, messages })
+    })
+    ws.on('message', (data) => {
+      const msg = JSON.parse(data.toString())
+      console.log(`[${role}] received raw:`, msg.type, msg.title || '', msg.content || '')
+      messages.push(msg)
+    })
+    ws.on('error', (err) => {
+      console.error(`[${role}] WebSocket error:`, err.message)
+      reject(err)
+    })
+    ws.on('close', () => {
+      console.log(`[${role}] WebSocket closed`)
+    })
+  })
+}
+
+function wait(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function filterMessages(messages, taskId, taskNo) {
+  return messages.filter(m => m.type !== 'connected' && m.type !== 'pong')
+    .filter(m => m.businessId == taskId || m.extraData == taskId || (m.content || '').includes(taskNo))
+}
+
+async function main() {
+  console.log('Connecting WebSockets...')
+  const sales = await connectWebSocket('sales')
+  const dispatch = await connectWebSocket('dispatch')
+  const construction = await connectWebSocket('construction')
+
+  await wait(1000)
+
+  // Step 1: Sales creates a task
+  console.log('\n--- Step 1: Sales creates task ---')
+  const taskNo = 'TK-TEST-' + Date.now()
+  const createRes = await request('POST', '/task', {
+    taskNo,
+    projectId: 33,
+    taskType: 'dredge',
+    planDate: '2026-07-26',
+    address: '测试地址',
+    taskName: '测试任务-' + Date.now(),
+  }, TOKENS.sales)
+  console.log('Create task response:', createRes.code, createRes.msg)
+  if (createRes.code !== 200) {
+    console.error('Failed to create task:', createRes)
+    process.exit(1)
+  }
+  const taskId = createRes.data
+
+  await wait(2000)
+
+  // Step 2: Dispatch approves task
+  console.log('\n--- Step 2: Dispatch approves task ---')
+  const approveRes = await request('POST', `/task/audit/${taskId}/approve`, {
+    auditUserId: ROLES.dispatch.userId,
+    auditUserName: ROLES.dispatch.name,
+  }, TOKENS.dispatch)
+  console.log('Approve task response:', approveRes.code, approveRes.msg)
+
+  await wait(2000)
+
+  // Step 3: Dispatch dispatches task to team 90
+  console.log('\n--- Step 3: Dispatch dispatches task to team 90 ---')
+  const vehicleRes = await request('GET', '/vehicle/list?pageNum=1&pageSize=1', null, TOKENS.dispatch)
+  const vehicleId = vehicleRes.data?.rows?.[0]?.vehicleId || 1
+  const dispatchRes = await request('POST', `/task/audit/${taskId}/dispatch`, {
+    teamId: 90,
+    vehicleId,
+    auditUserId: ROLES.dispatch.userId,
+    auditUserName: ROLES.dispatch.name,
+  }, TOKENS.dispatch)
+  console.log('Dispatch task response:', dispatchRes.code, dispatchRes.msg)
+
+  await wait(2000)
+
+  // Clear previous messages so we only capture reject/feedback notifications
+  sales.messages.length = 0
+  dispatch.messages.length = 0
+  construction.messages.length = 0
+
+  // Step 4: Construction rejects task
+  console.log('\n--- Step 4: Construction rejects task ---')
+  const taskRes = await request('GET', `/task/${taskId}`, null, TOKENS.construction)
+  const task = taskRes.data
+  const rejectReason = '测试拒绝原因-' + Date.now()
+  const rejectUpdateRes = await request('PUT', '/task', {
+    taskId,
+    taskNo: task.taskNo,
+    address: task.address,
+    taskType: task.taskType,
+    projectId: task.projectId,
+    planDate: task.planDate,
+    status: 'rejected',
+    remark: rejectReason,
+  }, TOKENS.construction)
+  console.log('Reject update response:', rejectUpdateRes.code, rejectUpdateRes.msg)
+
+  const rejectLogRes = await request('POST', `/task/${taskId}/log`, {
+    operationType: 'reject',
+    operation: '拒绝任务',
+    remark: rejectReason,
+    operatorId: ROLES.construction.userId,
+    operatorName: ROLES.construction.name,
+  }, TOKENS.construction)
+  console.log('Reject log response:', rejectLogRes.code, rejectLogRes.msg)
+
+  await wait(2000)
+
+  // Step 5: Construction submits feedback
+  console.log('\n--- Step 5: Construction submits feedback ---')
+  const feedbackContent = '测试反馈内容-' + Date.now()
+  const feedbackRes = await request('POST', `/task/${taskId}/log`, {
+    operationType: 'feedback',
+    operation: '反馈问题',
+    remark: feedbackContent,
+    operatorId: ROLES.construction.userId,
+    operatorName: ROLES.construction.name,
+  }, TOKENS.construction)
+  console.log('Feedback response:', feedbackRes.code, feedbackRes.msg)
+
+  await wait(2000)
+
+  // Step 6: Check task logs
+  console.log('\n--- Step 6: Task logs ---')
+  const logsRes = await request('GET', `/task/${taskId}/logs`, null, TOKENS.construction)
+  const logs = logsRes.data || []
+  console.log('Recent logs for task:')
+  logs.slice(-5).forEach(log => {
+    console.log(`  [${log.operationType}] ${log.operation} | ${log.remark || '-'} | by ${log.operatorName}`)
+  })
+  const hasRejectLog = logs.some(l => l.operationType === 'reject')
+  const hasFeedbackLog = logs.some(l => l.operationType === 'feedback')
+  console.log('Has reject log:', hasRejectLog)
+  console.log('Has feedback log:', hasFeedbackLog)
+
+  // Step 7: Check WebSocket messages received after reject/feedback
+  console.log('\n--- Step 7: WebSocket messages after reject/feedback ---')
+  console.log('Sales:', filterMessages(sales.messages, taskId, taskNo).map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Dispatch:', filterMessages(dispatch.messages, taskId, taskNo).map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Construction:', filterMessages(construction.messages, taskId, taskNo).map(m => ({ type: m.type, title: m.title, content: m.content })))
+
+  // Step 8: Check persisted messages
+  console.log('\n--- Step 8: Persisted messages ---')
+  const salesMsgRes = await request('GET', '/message/list?pageNum=1&pageSize=50', null, TOKENS.sales)
+  const dispatchMsgRes = await request('GET', '/message/list?pageNum=1&pageSize=50', null, TOKENS.dispatch)
+
+  console.log('Sales persisted messages related to task:',
+    salesMsgRes.data?.rows?.filter(m => m.extraData == taskId || m.content?.includes(taskNo)).map(m => ({ type: m.messageType, title: m.title, content: m.content, isRead: m.isRead })))
+  console.log('Dispatch persisted messages related to task:',
+    dispatchMsgRes.data?.rows?.filter(m => m.extraData == taskId || m.content?.includes(taskNo)).map(m => ({ type: m.messageType, title: m.title, content: m.content, isRead: m.isRead })))
+
+  sales.ws.close()
+  dispatch.ws.close()
+  construction.ws.close()
+
+  console.log('\n=== Summary ===')
+  console.log('Reject recorded in logs:', hasRejectLog)
+  console.log('Feedback recorded in logs:', hasFeedbackLog)
+  console.log('Sales WS notifications after reject/feedback:', filterMessages(sales.messages, taskId, taskNo).length)
+  console.log('Dispatch WS notifications after reject/feedback:', filterMessages(dispatch.messages, taskId, taskNo).length)
+  console.log('Sales persisted messages after reject/feedback:', (salesMsgRes.data?.rows || []).filter(m => m.extraData == taskId || m.content?.includes(taskNo)).length)
+  console.log('Dispatch persisted messages after reject/feedback:', (dispatchMsgRes.data?.rows || []).filter(m => m.extraData == taskId || m.content?.includes(taskNo)).length)
+}
+
+main().catch((err) => {
+  console.error('Test failed:', err)
+  process.exit(1)
+})

+ 111 - 0
scripts/verify-home-stats.js

@@ -0,0 +1,111 @@
+const http = require('http')
+
+const BASE_URL = 'http://localhost:8080/api'
+
+const TOKENS = {
+  sales: 'mock-token-mp-21',
+  dispatch: 'mock-token-mp-13',
+  construction: 'mock-token-mp-15',
+}
+
+function formatDate(date) {
+  const y = date.getFullYear()
+  const m = String(date.getMonth() + 1).padStart(2, '0')
+  const d = String(date.getDate()).padStart(2, '0')
+  return `${y}-${m}-${d}`
+}
+
+const today = formatDate(new Date())
+const tomorrowDate = new Date()
+tomorrowDate.setDate(tomorrowDate.getDate() + 1)
+const tomorrow = formatDate(tomorrowDate)
+
+function request(method, path, token, data) {
+  return new Promise((resolve, reject) => {
+    const options = {
+      hostname: 'localhost',
+      port: 8080,
+      path: '/api' + path,
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`,
+      },
+    }
+    const req = http.request(options, (res) => {
+      let body = ''
+      res.on('data', (chunk) => (body += chunk))
+      res.on('end', () => {
+        try {
+          resolve(JSON.parse(body))
+        } catch (e) {
+          resolve(body)
+        }
+      })
+    })
+    req.on('error', reject)
+    if (data) req.write(JSON.stringify(data))
+    req.end()
+  })
+}
+
+function countByStatus(tasks, statuses) {
+  return tasks.filter(t => statuses.includes(t.status)).length
+}
+
+function countByDate(tasks, date) {
+  return tasks.filter(t => {
+    if (t.planDate === date) return true
+    if (t.planDate && t.planEndDate && date >= t.planDate && date <= t.planEndDate) return true
+    if (t.scheduleDate === date) return true
+    if (t.serviceDate === date) return true
+    return false
+  }).length
+}
+
+async function main() {
+  // 销售端:项目总数、待确认(auditing)、进行中(in_progress)
+  const projectRes = await request('GET', '/project/list?pageNum=1&pageSize=1', TOKENS.sales)
+  const salesTaskRes = await request('GET', '/task/list?pageNum=1&pageSize=100', TOKENS.sales)
+  const salesTasks = salesTaskRes.data?.rows || []
+
+  console.log('=== 销售端首页 ===')
+  console.log('本月项目:', projectRes.data?.total || 0)
+  console.log('待确认 (status=auditing):', countByStatus(salesTasks, ['auditing']))
+  console.log('进行中 (status=in_progress):', countByStatus(salesTasks, ['in_progress']))
+  console.log('任务状态分布:', salesTasks.reduce((acc, t) => {
+    acc[t.status] = (acc[t.status] || 0) + 1
+    return acc
+  }, {}))
+
+  // 调度端:今日任务、待审核(auditing)、待安排(approved)
+  const dispatchMyRes = await request('GET', `/task/my?planDate=${today}`, TOKENS.dispatch)
+  const dispatchTasks = dispatchMyRes.data || dispatchMyRes || []
+
+  console.log('\n=== 调度端首页 ===')
+  console.log('今日任务 (planDate/scheduleDate/serviceDate = today):', countByDate(dispatchTasks, today), '/', dispatchTasks.length)
+  console.log('待审核 (status=auditing):', countByStatus(dispatchTasks, ['auditing']))
+  console.log('待安排 (status=approved):', countByStatus(dispatchTasks, ['approved']))
+  console.log('任务状态分布:', dispatchTasks.reduce((acc, t) => {
+    acc[t.status] = (acc[t.status] || 0) + 1
+    return acc
+  }, {}))
+
+  // 施工端:已完成(completed)、进行中(in_progress)、待确认(assigned)
+  const constructionMyRes = await request('GET', '/task/my', TOKENS.construction)
+  const constructionTasks = constructionMyRes.data || constructionMyRes || []
+
+  console.log('\n=== 施工端首页 ===')
+  console.log('已完成 (status=completed):', countByStatus(constructionTasks, ['completed']))
+  console.log('进行中 (status=in_progress):', countByStatus(constructionTasks, ['in_progress']))
+  console.log('待确认 (status=assigned):', countByStatus(constructionTasks, ['assigned']))
+  console.log('任务状态分布:', constructionTasks.reduce((acc, t) => {
+    acc[t.status] = (acc[t.status] || 0) + 1
+    return acc
+  }, {}))
+}
+
+main().catch(err => {
+  console.error('验证失败:', err)
+  process.exit(1)
+})

+ 197 - 0
scripts/verify-notifications.js

@@ -0,0 +1,197 @@
+const WebSocket = require('ws')
+const http = require('http')
+
+const BASE_URL = 'http://localhost:8080/api'
+const WS_URL = 'ws://localhost:8080/api/ws/notify'
+
+const TOKENS = {
+  sales: 'mock-token-mp-21',
+  dispatch: 'mock-token-mp-13',
+  construction: 'mock-token-mp-15',
+}
+
+const ROLES = {
+  sales: { userId: 21, name: '徐云辉' },
+  dispatch: { userId: 13, name: '张智城' },
+  construction: { userId: 15, name: '测试实施1' },
+}
+
+function request(method, path, data, token) {
+  return new Promise((resolve, reject) => {
+    const options = {
+      hostname: 'localhost',
+      port: 8080,
+      path: '/api' + path,
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`,
+      },
+    }
+    const req = http.request(options, (res) => {
+      let body = ''
+      res.on('data', (chunk) => (body += chunk))
+      res.on('end', () => {
+        try {
+          resolve(JSON.parse(body))
+        } catch (e) {
+          resolve(body)
+        }
+      })
+    })
+    req.on('error', reject)
+    if (data) req.write(JSON.stringify(data))
+    req.end()
+  })
+}
+
+function connectWebSocket(role) {
+  return new Promise((resolve, reject) => {
+    const ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(TOKENS[role])}`)
+    const messages = []
+    ws.on('open', () => {
+      console.log(`[${role}] WebSocket connected`)
+      resolve({ ws, messages })
+    })
+    ws.on('message', (data) => {
+      const msg = JSON.parse(data.toString())
+      console.log(`[${role}] received raw:`, msg.type, msg.title || '', msg.content || '')
+      messages.push(msg)
+    })
+    ws.on('error', (err) => {
+      console.error(`[${role}] WebSocket error:`, err.message)
+      reject(err)
+    })
+    ws.on('close', () => {
+      console.log(`[${role}] WebSocket closed`)
+    })
+  })
+}
+
+function wait(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+async function main() {
+  console.log('Connecting WebSockets...')
+  const sales = await connectWebSocket('sales')
+  const dispatch = await connectWebSocket('dispatch')
+  const construction = await connectWebSocket('construction')
+
+  await wait(1000)
+
+  // Step 1: Sales creates a task
+  console.log('\n--- Step 1: Sales creates task ---')
+  const taskNo = 'TK-TEST-' + Date.now()
+  const createRes = await request('POST', '/task', {
+    taskNo,
+    projectId: 33,
+    taskType: 'dredge',
+    planDate: '2026-07-26',
+    address: '测试地址',
+    taskName: '测试任务-' + Date.now(),
+  }, TOKENS.sales)
+  console.log('Create task response:', createRes.code, createRes.msg)
+  if (createRes.code !== 200) {
+    console.error('Failed to create task:', createRes)
+    process.exit(1)
+  }
+  const taskId = createRes.data
+
+  await wait(2000)
+
+  console.log('\n--- Messages after create ---')
+  console.log('Sales messages:', sales.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Dispatch messages:', dispatch.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Construction messages:', construction.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+
+  // Step 2: Dispatch approves task
+  console.log('\n--- Step 2: Dispatch approves task ---')
+  const approveRes = await request('POST', `/task/audit/${taskId}/approve`, {
+    auditUserId: ROLES.dispatch.userId,
+    auditUserName: ROLES.dispatch.name,
+  }, TOKENS.dispatch)
+  console.log('Approve task response:', approveRes.code, approveRes.msg)
+
+  await wait(2000)
+
+  console.log('\n--- Messages after approve ---')
+  console.log('Sales messages:', sales.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Dispatch messages:', dispatch.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+
+  // Step 3: Dispatch dispatches task to construction team 90
+  console.log('\n--- Step 3: Dispatch dispatches task to team 90 ---')
+  // Need a vehicle. Let's query vehicles first.
+  const vehicleRes = await request('GET', '/vehicle/list?pageNum=1&pageSize=1', null, TOKENS.dispatch)
+  const vehicleId = vehicleRes.data?.rows?.[0]?.vehicleId || 1
+  const dispatchRes = await request('POST', `/task/audit/${taskId}/dispatch`, {
+    teamId: 90,
+    vehicleId,
+    auditUserId: ROLES.dispatch.userId,
+    auditUserName: ROLES.dispatch.name,
+  }, TOKENS.dispatch)
+  console.log('Dispatch task response:', dispatchRes.code, dispatchRes.msg)
+
+  await wait(2000)
+
+  console.log('\n--- Messages after dispatch ---')
+  console.log('Sales messages:', sales.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Dispatch messages:', dispatch.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Construction messages:', construction.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+
+  // Step 4: Construction confirms task
+  console.log('\n--- Step 4: Construction confirms task ---')
+  const confirmRes = await request('POST', '/sop/execute-step', {
+    taskId,
+    stepCode: 'confirm',
+    action: 'confirm',
+    operatorId: ROLES.construction.userId,
+    operatorName: ROLES.construction.name,
+  }, TOKENS.construction)
+  console.log('Confirm task response:', confirmRes.code, confirmRes.msg)
+
+  await wait(2000)
+
+  console.log('\n--- Messages after confirm ---')
+  console.log('Sales messages:', sales.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Dispatch messages:', dispatch.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+
+  // Step 5: Construction completes task (SOP finish step)
+  console.log('\n--- Step 5: Construction completes task ---')
+  const completeRes = await request('POST', '/sop/execute-step', {
+    taskId,
+    stepCode: 'finish',
+    action: 'finish',
+    operatorId: ROLES.construction.userId,
+    operatorName: ROLES.construction.name,
+  }, TOKENS.construction)
+  console.log('Complete task response:', completeRes.code, completeRes.msg)
+
+  await wait(2000)
+
+  console.log('\n--- Messages after complete ---')
+  console.log('Sales messages:', sales.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+  console.log('Dispatch messages:', dispatch.messages.filter(m => m.type !== 'connected' && m.type !== 'pong').map(m => ({ type: m.type, title: m.title, content: m.content })))
+
+  // Check persisted messages
+  console.log('\n--- Persisted messages for sales ---')
+  const salesMsgRes = await request('GET', '/message/list?pageNum=1&pageSize=50', null, TOKENS.sales)
+  console.log('Sales persisted messages:', salesMsgRes.data?.rows?.filter(m => m.extraData == taskId || m.content?.includes(taskNo)).map(m => ({ type: m.messageType, title: m.title, content: m.content, isRead: m.isRead })))
+
+  console.log('\n--- Persisted messages for dispatch ---')
+  const dispatchMsgRes = await request('GET', '/message/list?pageNum=1&pageSize=50', null, TOKENS.dispatch)
+  console.log('Dispatch persisted messages:', dispatchMsgRes.data?.rows?.filter(m => m.extraData == taskId || m.content?.includes(taskNo)).map(m => ({ type: m.messageType, title: m.title, content: m.content, isRead: m.isRead })))
+
+  console.log('\n--- Persisted messages for construction ---')
+  const constructionMsgRes = await request('GET', '/message/list?pageNum=1&pageSize=50', null, TOKENS.construction)
+  console.log('Construction persisted messages:', constructionMsgRes.data?.rows?.filter(m => m.extraData == taskId || m.content?.includes(taskNo)).map(m => ({ type: m.messageType, title: m.title, content: m.content, isRead: m.isRead })))
+
+  sales.ws.close()
+  dispatch.ws.close()
+  construction.ws.close()
+}
+
+main().catch((err) => {
+  console.error('Test failed:', err)
+  process.exit(1)
+})

+ 0 - 0
scripts/write-plan.js


+ 21 - 3
src/App.vue

@@ -2,6 +2,7 @@
 import { onLaunch, onShow } from "@dcloudio/uni-app";
 import { useAuthStore } from "./stores/auth";
 import { useDictStore } from "./stores/dict";
+import { useNotificationStore } from "./stores/notification";
 import { safeHideTabBar } from "./utils";
 
 onLaunch(() => {
@@ -12,21 +13,38 @@ onLaunch(() => {
   const dictStore = useDictStore();
   dictStore.loadCommonDicts();
 
+  // 若已登录,建立 WebSocket 连接
+  if (authStore.isLoggedIn && authStore.token) {
+    const notificationStore = useNotificationStore();
+    notificationStore.connectSocket(authStore.token);
+    notificationStore.fetchUnreadCount();
+  }
+
   // #ifdef H5 || APP-PLUS
-  safeHideTabBar({ animation: false })
+  // App-PLUS 原生 tabBar 仍会显示,需要强制隐藏;H5 用 CSS 兜底
+  safeHideTabBar({ animation: false, repeat: 12 })
   // #endif
 });
 
 onShow(() => {
   console.log("App Show");
+  const authStore = useAuthStore();
+  if (authStore.isLoggedIn && authStore.token) {
+    const notificationStore = useNotificationStore();
+    notificationStore.ensureConnected(authStore.token);
+    notificationStore.fetchUnreadCount();
+  }
+  // #ifdef H5 || APP-PLUS
+  safeHideTabBar({ animation: false, repeat: 10 })
+  // #endif
 });
 </script>
 
 <style>
 @import "@/styles/uni-icons.css";
 
-/* #ifdef H5 */
-/* 隐藏 H5 原生 tabBar,统一使用自定义 AppTabBar(按角色显示不同 tab) */
+/* #ifdef H5 || APP-PLUS */
+/* 隐藏 H5/App 原生 tabBar,统一使用自定义 AppTabBar(按角色显示不同 tab) */
 .uni-tabbar {
   display: none !important;
 }

+ 2 - 1
src/api/knowledge.ts

@@ -4,6 +4,7 @@ export interface KnowledgeItem {
   id: number
   title: string
   category: string
+  cover?: string
   content: string
   readCount: number
   createTime: string
@@ -39,6 +40,6 @@ export function categoryName(key?: string): string {
 
 export function summaryOf(content?: string, len = 50): string {
   if (!content) return ''
-  const text = content.replace(/\s+/g, ' ').trim()
+  const text = content.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
   return text.length > len ? text.slice(0, len) + '...' : text
 }

+ 1 - 1
src/api/schedule.ts

@@ -29,7 +29,7 @@ export function createSchedule(data: DispatchSchedule) {
 }
 
 export function updateSchedule(scheduleId: number, data: DispatchSchedule) {
-  return put(`/schedule/${scheduleId}`, data)
+  return put('/schedule', { scheduleId, ...data })
 }
 
 export function deleteSchedule(scheduleId: number) {

+ 12 - 0
src/api/task.ts

@@ -123,6 +123,18 @@ export function getTaskLogs(taskId: number) {
   return get(`/task/${taskId}/logs`)
 }
 
+export interface TaskLogData {
+  operation?: string
+  operationType?: string
+  remark?: string
+  operatorId?: number
+  operatorName?: string
+}
+
+export function addTaskLog(taskId: number, data: TaskLogData) {
+  return post(`/task/${taskId}/log`, data)
+}
+
 export interface AuditParams {
   remark?: string
   auditUserId?: number | string

+ 232 - 87
src/components/common/ChartView.vue

@@ -11,7 +11,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, watch } from 'vue'
+import { ref, onMounted, watch, computed, getCurrentInstance } from 'vue'
 
 interface Props {
   type: 'bar' | 'line' | 'pie' | 'ring'
@@ -23,11 +23,13 @@ interface Props {
 
 const props = withDefaults(defineProps<Props>(), {
   height: 200,
-  canvasId: 'chart-canvas',
+  canvasId: '',
   title: '',
 })
 
-const colors = ['#4f8ef7', '#34c759', '#ff9500', '#ff3b30', '#5856d6', '#ff2d55', '#5ac8fa', '#af52de']
+const colors = ['#368f6f', '#4f8ef7', '#ff9500', '#8e8e93', '#5856d6', '#ff2d55', '#5ac8fa', '#af52de']
+
+const canvasId = computed(() => props.canvasId || `chart-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
 
 let ctx: UniApp.CanvasContext | null = null
 
@@ -40,36 +42,64 @@ watch(() => props.data, () => {
 }, { deep: true })
 
 function drawChart() {
-  ctx = uni.createCanvasContext(props.canvasId)
-  if (!ctx) return
+  const instance = getCurrentInstance()
+  if (!instance) return
 
-  const { windowWidth } = uni.getSystemInfoSync()
-  const canvasWidth = windowWidth - 32 // 减去padding
-  const canvasHeight = props.height
+  const query = uni.createSelectorQuery().in(instance.proxy)
+  query.select('.chart-container').boundingClientRect((rect: any) => {
+    const windowWidth = uni.getSystemInfoSync().windowWidth
+    const pageMaxWidth = 430
+    const rectWidth = rect ? rect.width : windowWidth - 64
+    const cssWidth = Math.min(rectWidth, pageMaxWidth - 64, windowWidth - 64)
+    const cssHeight = props.height
 
-  ctx.clearRect(0, 0, canvasWidth, canvasHeight)
+    ctx = uni.createCanvasContext(canvasId.value)
+    if (!ctx) return
 
-  if (props.type === 'pie' || props.type === 'ring') {
-    drawPie(canvasWidth, canvasHeight)
-  } else if (props.type === 'bar') {
-    drawBar(canvasWidth, canvasHeight)
-  } else if (props.type === 'line') {
-    drawLine(canvasWidth, canvasHeight)
-  }
+    ctx.clearRect(0, 0, cssWidth, cssHeight)
+
+    if (!props.data || props.data.length === 0) {
+      drawEmpty(cssWidth, cssHeight)
+      return
+    }
+
+    if (props.type === 'pie' || props.type === 'ring') {
+      drawPie(cssWidth, cssHeight)
+    } else if (props.type === 'bar') {
+      drawBar(cssWidth, cssHeight)
+    } else if (props.type === 'line') {
+      drawLine(cssWidth, cssHeight)
+    }
 
+    ctx.draw()
+  })
+  query.exec()
+}
+
+function drawEmpty(width: number, height: number) {
+  if (!ctx) return
+  ctx.setFontSize(14)
+  ctx.setFillStyle('#9ca3af')
+  ctx.setTextAlign('center')
+  ctx.fillText('暂无数据', width / 2, height / 2)
   ctx.draw()
 }
 
 function drawPie(width: number, height: number) {
   if (!ctx) return
   const total = props.data.reduce((sum, item) => sum + item.value, 0)
-  let startAngle = -Math.PI / 2
+  const isRing = props.type === 'ring'
+  const padding = 16
+  const legendHeight = 28
+  const chartHeight = height - legendHeight
   const centerX = width / 2
-  const centerY = height / 2
-  const radius = Math.min(centerX, centerY) - 40
+  const centerY = chartHeight / 2
+  const radius = Math.min(centerX, centerY) - padding
+
+  let startAngle = -Math.PI / 2
 
   props.data.forEach((item, index) => {
-    const angle = (item.value / total) * Math.PI * 2
+    const angle = total === 0 ? 0 : (item.value / total) * Math.PI * 2
     const endAngle = startAngle + angle
     const color = item.color || colors[index % colors.length]
 
@@ -80,117 +110,226 @@ function drawPie(width: number, height: number) {
     ctx!.setFillStyle(color)
     ctx!.fill()
 
-    // 标签
-    const midAngle = startAngle + angle / 2
-    const labelX = centerX + Math.cos(midAngle) * (radius + 15)
-    const labelY = centerY + Math.sin(midAngle) * (radius + 15)
-    ctx!.setFontSize(10)
-    ctx!.setFillStyle('#666')
-    ctx!.fillText(`${item.label}`, labelX - 15, labelY)
-    ctx!.fillText(`${item.value}`, labelX - 5, labelY + 12)
-
     startAngle = endAngle
   })
 
-  // 如果是 ring 类型,绘制中心圆
-  if (props.type === 'ring') {
+  if (isRing) {
     ctx!.beginPath()
-    ctx!.arc(centerX, centerY, radius * 0.6, 0, Math.PI * 2)
-    ctx!.setFillStyle('#fff')
+    ctx!.arc(centerX, centerY, radius * 0.55, 0, Math.PI * 2)
+    ctx!.setFillStyle('rgba(255, 255, 255, 0.85)')
     ctx!.fill()
-    ctx!.setFontSize(14)
-    ctx!.setFillStyle('#333')
+
+    ctx!.setFontSize(12)
+    ctx!.setFillStyle('#6b7280')
     ctx!.setTextAlign('center')
-    ctx!.fillText('总计', centerX, centerY - 5)
-    ctx!.fillText(String(total), centerX, centerY + 15)
+    ctx!.fillText('总计', centerX, centerY - 6)
+
+    ctx!.setFontSize(18)
+    ctx!.setFillStyle('#1f2937')
+    ctx!.setTextAlign('center')
+    ctx!.fillText(String(total), centerX, centerY + 16)
   }
+
+  // 图例
+  drawLegend(width, height - legendHeight + 10)
+}
+
+function drawLegend(width: number, y: number) {
+  if (!ctx) return
+  const total = props.data.reduce((sum, item) => sum + item.value, 0)
+  const itemWidth = width / props.data.length
+
+  props.data.forEach((item, index) => {
+    const color = item.color || colors[index % colors.length]
+    const x = index * itemWidth + itemWidth / 2
+
+    ctx!.setFillStyle(color)
+    ctx!.fillRect(x - 28, y, 8, 8)
+
+    ctx!.setFontSize(10)
+    ctx!.setFillStyle('#6b7280')
+    ctx!.setTextAlign('left')
+    ctx!.fillText(item.label, x - 16, y + 8)
+
+    ctx!.setFontSize(10)
+    ctx!.setFillStyle('#1f2937')
+    ctx!.fillText(String(item.value), x + 12, y + 8)
+  })
 }
 
 function drawBar(width: number, height: number) {
   if (!ctx) return
-  const padding = 30
-  const chartHeight = height - padding * 2
-  const chartWidth = width - padding * 2
+  const padding = { top: 24, right: 16, bottom: 36, left: 40 }
+  const chartHeight = height - padding.top - padding.bottom
+  const chartWidth = width - padding.left - padding.right
   const maxValue = Math.max(...props.data.map(d => d.value))
-  const barWidth = (chartWidth / props.data.length) * 0.6
-  const gap = (chartWidth / props.data.length) * 0.4
+  const safeMax = maxValue <= 0 ? 1 : maxValue * 1.1
+  const barWidth = (chartWidth / props.data.length) * 0.55
+  const gap = (chartWidth / props.data.length) * 0.45
+
+  // 网格线
+  ctx.setStrokeStyle('rgba(164, 216, 152, 0.25)')
+  ctx.setLineWidth(1)
+  for (let i = 0; i <= 4; i++) {
+    const y = padding.top + (chartHeight / 4) * i
+    ctx.beginPath()
+    ctx.moveTo(padding.left, y)
+    ctx.lineTo(width - padding.right, y)
+    ctx.stroke()
+  }
 
-  // 绘制坐标轴
-  ctx.setStrokeStyle('#e0e0e0')
+  // 坐标轴
+  ctx.setStrokeStyle('#d1d5db')
   ctx.beginPath()
-  ctx.moveTo(padding, padding)
-  ctx.lineTo(padding, height - padding)
-  ctx.lineTo(width - padding, height - padding)
+  ctx.moveTo(padding.left, padding.top)
+  ctx.lineTo(padding.left, height - padding.bottom)
+  ctx.lineTo(width - padding.right, height - padding.bottom)
   ctx.stroke()
 
-  // 绘制柱状图
+  // Y 轴刻度
+  ctx.setFontSize(10)
+  ctx.setFillStyle('#9ca3af')
+  ctx.setTextAlign('right')
+  for (let i = 0; i <= 4; i++) {
+    const value = Math.round((safeMax / 4) * (4 - i))
+    const y = padding.top + (chartHeight / 4) * i + 4
+    ctx.fillText(String(value), padding.left - 6, y)
+  }
+
+  // 柱状图
   props.data.forEach((item, index) => {
-    const x = padding + gap / 2 + index * (barWidth + gap)
-    const barHeight = (item.value / maxValue) * chartHeight
-    const y = height - padding - barHeight
+    const x = padding.left + gap / 2 + index * (barWidth + gap)
+    const barHeight = (item.value / safeMax) * chartHeight
+    const y = height - padding.bottom - barHeight
     const color = item.color || colors[index % colors.length]
 
     ctx!.setFillStyle(color)
     ctx!.fillRect(x, y, barWidth, barHeight)
 
+    // 圆角顶部
+    ctx!.beginPath()
+    ctx!.arc(x + barWidth / 2, y, barWidth / 2, Math.PI, 0)
+    ctx!.setFillStyle(color)
+    ctx!.fill()
+
     // 数值
-    ctx!.setFontSize(10)
-    ctx!.setFillStyle('#333')
-    ctx!.fillText(String(item.value), x + barWidth / 2 - 5, y - 5)
+    if (item.value > 0) {
+      ctx!.setFontSize(10)
+      ctx!.setFillStyle('#1f2937')
+      ctx!.setTextAlign('center')
+      ctx!.fillText(String(item.value), x + barWidth / 2, y - 6)
+    }
 
     // 标签
     ctx!.setFontSize(10)
-    ctx!.setFillStyle('#666')
-    ctx!.fillText(item.label, x, height - padding + 15)
+    ctx!.setFillStyle('#6b7280')
+    ctx!.setTextAlign('center')
+    ctx!.fillText(item.label, x + barWidth / 2, height - padding.bottom + 14)
   })
 }
 
 function drawLine(width: number, height: number) {
   if (!ctx) return
-  const padding = 30
-  const chartHeight = height - padding * 2
-  const chartWidth = width - padding * 2
+  const padding = { top: 24, right: 16, bottom: 36, left: 40 }
+  const chartHeight = height - padding.top - padding.bottom
+  const chartWidth = width - padding.left - padding.right
   const maxValue = Math.max(...props.data.map(d => d.value))
-  const pointGap = chartWidth / (props.data.length - 1)
+  const allZero = maxValue <= 0
+  const safeMax = allZero ? 1 : maxValue * 1.1
+  const pointGap = props.data.length > 1 ? chartWidth / (props.data.length - 1) : chartWidth / 2
+
+  // 网格线
+  ctx.setStrokeStyle('rgba(164, 216, 152, 0.25)')
+  ctx.setLineWidth(1)
+  for (let i = 0; i <= 4; i++) {
+    const y = padding.top + (chartHeight / 4) * i
+    ctx.beginPath()
+    ctx.moveTo(padding.left, y)
+    ctx.lineTo(width - padding.right, y)
+    ctx.stroke()
+  }
 
-  // 绘制坐标轴
-  ctx.setStrokeStyle('#e0e0e0')
+  // 坐标轴
+  ctx.setStrokeStyle('#d1d5db')
   ctx.beginPath()
-  ctx.moveTo(padding, padding)
-  ctx.lineTo(padding, height - padding)
-  ctx.lineTo(width - padding, height - padding)
+  ctx.moveTo(padding.left, padding.top)
+  ctx.lineTo(padding.left, height - padding.bottom)
+  ctx.lineTo(width - padding.right, height - padding.bottom)
   ctx.stroke()
 
-  // 绘制线条
-  ctx.setStrokeStyle('#4f8ef7')
-  ctx.setLineWidth(2)
-  ctx.beginPath()
+  // Y 轴刻度
+  ctx.setFontSize(10)
+  ctx.setFillStyle('#9ca3af')
+  ctx.setTextAlign('right')
+  for (let i = 0; i <= 4; i++) {
+    const value = Math.round((safeMax / 4) * (4 - i))
+    const y = padding.top + (chartHeight / 4) * i + 4
+    ctx.fillText(String(value), padding.left - 6, y)
+  }
 
   const points: { x: number; y: number }[] = []
   props.data.forEach((item, index) => {
-    const x = padding + index * pointGap
-    const y = height - padding - (item.value / maxValue) * chartHeight
+    const x = padding.left + index * pointGap
+    const y = height - padding.bottom - (item.value / safeMax) * chartHeight
     points.push({ x, y })
-    if (index === 0) {
-      ctx!.moveTo(x, y)
-    } else {
-      ctx!.lineTo(x, y)
-    }
   })
-  ctx.stroke()
 
-  // 绘制数据点和数值
-  points.forEach((point, index) => {
-    ctx!.beginPath()
-    ctx!.arc(point.x, point.y, 4, 0, Math.PI * 2)
-    ctx!.setFillStyle('#4f8ef7')
-    ctx!.fill()
+  if (allZero) {
+    ctx.setFontSize(12)
+    ctx.setFillStyle('#9ca3af')
+    ctx.setTextAlign('center')
+    ctx.fillText('暂无数据', width / 2, height / 2)
+  } else {
+    // 填充面积
+    ctx.beginPath()
+    ctx.moveTo(points[0].x, height - padding.bottom)
+    points.forEach((point) => {
+      ctx!.lineTo(point.x, point.y)
+    })
+    ctx.lineTo(points[points.length - 1].x, height - padding.bottom)
+    ctx.closePath()
+    ctx.setFillStyle('rgba(54, 143, 111, 0.12)')
+    ctx.fill()
+
+    // 绘制线条
+    ctx.setStrokeStyle('#368f6f')
+    ctx.setLineWidth(2)
+    ctx.beginPath()
+    points.forEach((point, index) => {
+      if (index === 0) {
+        ctx!.moveTo(point.x, point.y)
+      } else {
+        ctx!.lineTo(point.x, point.y)
+      }
+    })
+    ctx.stroke()
+
+    // 数据点和数值
+    points.forEach((point, index) => {
+      ctx!.beginPath()
+      ctx!.arc(point.x, point.y, 4, 0, Math.PI * 2)
+      ctx!.setFillStyle('#ffffff')
+      ctx!.fill()
+
+      ctx!.beginPath()
+      ctx!.arc(point.x, point.y, 4, 0, Math.PI * 2)
+      ctx!.setStrokeStyle('#368f6f')
+      ctx!.setLineWidth(2)
+      ctx!.stroke()
+
+      ctx!.setFontSize(10)
+      ctx!.setFillStyle('#1f2937')
+      ctx!.setTextAlign('center')
+      ctx!.fillText(String(props.data[index].value), point.x, point.y - 10)
+    })
+  }
 
+  // X 轴标签
+  points.forEach((point, index) => {
     ctx!.setFontSize(10)
-    ctx!.setFillStyle('#333')
-    ctx!.fillText(String(props.data[index].value), point.x - 5, point.y - 10)
-    ctx!.setFillStyle('#666')
-    ctx!.fillText(props.data[index].label, point.x - 10, height - padding + 15)
+    ctx!.setFillStyle('#6b7280')
+    ctx!.setTextAlign('center')
+    ctx!.fillText(props.data[index].label, point.x, height - padding.bottom + 14)
   })
 }
 
@@ -204,3 +343,9 @@ function touchMove(e: any) {
   // 可以添加滑动交互
 }
 </script>
+
+<style scoped>
+.chart-container {
+  width: 100%;
+}
+</style>

+ 104 - 70
src/components/home/ConstructionHome.vue

@@ -62,34 +62,15 @@
             hover-class="stat-hover"
             :hover-start-time="0"
             :hover-stay-time="120"
-            @click="goToTomorrowTask"
+            @click="goToTaskList"
           >
             <view class="stat-bar"></view>
-            <text class="stat-value">{{ tomorrowCount }}</text>
-            <text class="stat-label">明日任务</text>
+            <text class="stat-value">{{ taskStore.assignedCount }}</text>
+            <text class="stat-label">待确认</text>
           </view>
         </view>
       </view>
 
-      <!-- 每日任务入口 -->
-      <view
-        class="glass-card reminder-row"
-        hover-class="reminder-hover"
-        :hover-start-time="0"
-        :hover-stay-time="120"
-        @click="goToDailyTask"
-      >
-        <view class="icon-chip mr-3">
-          <text class="uni-icons uniui-gear-filled chip-glyph"></text>
-        </view>
-        <view class="flex-1 min-w-0">
-          <text class="reminder-text block">每日任务</text>
-          <text class="row-sub truncate">今日检查待完成</text>
-        </view>
-        <view class="reminder-dot mr-2"></view>
-        <text class="uni-icons uniui-arrowright row-arrow"></text>
-      </view>
-
       <!-- 快捷入口 -->
       <view class="glass-card p-4">
         <view class="grid grid-cols-3 gap-3">
@@ -189,6 +170,37 @@
           <text style="font-size: 12px; color: #9ca3af;">暂无通知</text>
         </view>
       </view>
+
+      <!-- 行业知识 -->
+      <view>
+        <view class="section-head px-4">
+          <view class="section-bar mr-2"></view>
+          <text class="section-title flex-1">行业知识</text>
+          <text class="section-more" @click="goToKnowledgeList">更多</text>
+        </view>
+        <view class="knowledge-list px-4 pb-4">
+          <view
+            v-for="item in knowledgeList"
+            :key="item.id"
+            class="knowledge-card"
+            hover-class="knowledge-card-hover"
+            :hover-start-time="0"
+            :hover-stay-time="120"
+            @click="goToKnowledgeDetail(item.id)"
+          >
+            <view v-if="item.cover" class="knowledge-cover">
+              <image :src="item.cover" mode="aspectFill" class="w-full h-full" />
+            </view>
+            <view v-else class="knowledge-cover knowledge-cover-placeholder">
+              <text class="uni-icons uniui-chart knowledge-cover-icon"></text>
+            </view>
+            <text class="knowledge-card-title truncate">{{ item.title }}</text>
+          </view>
+          <view v-if="knowledgeList.length === 0" class="py-6 text-center">
+            <text class="empty-text">暂无知识内容</text>
+          </view>
+        </view>
+      </view>
     </view>
   </view>
 </template>
@@ -201,6 +213,7 @@ import { useNotificationStore } from '@/stores/notification'
 import StatusBar from '@/components/common/StatusBar.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import { getNoticeList } from '@/api/notice'
+import { getKnowledgeList, type KnowledgeItem } from '@/api/knowledge'
 import { formatDate } from '@/utils'
 
 const authStore = useAuthStore()
@@ -210,23 +223,17 @@ const notificationStore = useNotificationStore()
 const userName = ref(authStore.user?.name || '张师傅')
 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
+  taskStore.tasks.filter(t => t.status === 'in_progress').length
 )
 const currentTasks = computed(() =>
-  taskStore.tasks.filter(t => ['assigned', 'in_progress'].includes(t.status)).slice(0, 2) as any[]
+  taskStore.tasks.filter(t => t.status === 'in_progress').slice(0, 2) as any[]
 )
 
 function taskIdOf(task: any): string {
-  return String(task?.id ?? task?.taskId ?? '')
+  const id = task?.id ?? task?.taskId ?? ''
+  return id && !Number.isNaN(id) ? String(id) : ''
 }
 
 const greetText = computed(() => {
@@ -239,6 +246,7 @@ const greetText = computed(() => {
 })
 
 const notices = ref<any[]>([])
+const knowledgeList = ref<KnowledgeItem[]>([])
 
 function goToTaskList() {
   uni.switchTab({ url: '/pages/task/task' })
@@ -252,10 +260,6 @@ function goToHistoryTask() {
   uni.navigateTo({ url: '/subPackages/pages-construction/historyTask' })
 }
 
-function goToTomorrowTask() {
-  uni.navigateTo({ url: '/subPackages/pages-construction/tomorrowTask' })
-}
-
 function goToHelpCenter() {
   uni.navigateTo({ url: '/subPackages/pages-common/helpCenter' })
 }
@@ -265,7 +269,7 @@ function goToNotification() {
 }
 
 function goToTaskDetail(taskId: string) {
-  if (!taskId) return
+  if (!taskId || taskId === 'NaN') return
   uni.navigateTo({ url: `/subPackages/pages-construction/taskDetail?id=${taskId}` })
 }
 
@@ -277,6 +281,14 @@ function goToNoticeDetail(noticeId: number) {
   uni.navigateTo({ url: `/subPackages/pages-construction/noticeDetail?id=${noticeId}` })
 }
 
+function goToKnowledgeList() {
+  uni.navigateTo({ url: '/subPackages/pages-common/knowledgeList' })
+}
+
+function goToKnowledgeDetail(id: number) {
+  uni.navigateTo({ url: `/subPackages/pages-common/knowledgeDetail?id=${id}` })
+}
+
 onMounted(async () => {
   taskStore.fetchMyTasks()
   notificationStore.fetchUnreadCount()
@@ -286,6 +298,12 @@ onMounted(async () => {
   } catch (error) {
     console.error('获取通知列表失败:', error)
   }
+  try {
+    const res = await getKnowledgeList(1, 2)
+    knowledgeList.value = res.rows || []
+  } catch (error) {
+    console.error('获取知识列表失败:', error)
+  }
 })
 </script>
 
@@ -428,7 +446,7 @@ onMounted(async () => {
   backdrop-filter: blur(14px);
   -webkit-backdrop-filter: blur(14px);
   border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
+  border-radius: 16px;
   box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
   overflow: hidden;
   margin-bottom: 12px;
@@ -443,7 +461,7 @@ onMounted(async () => {
   justify-content: center;
   min-height: 88px;
   padding: 14px 6px 12px;
-  border-radius: 16px;
+  border-radius: 12px;
   background-color: rgba(164, 216, 152, 0.14);
   border: 1px solid rgba(164, 216, 152, 0.3);
   overflow: hidden;
@@ -478,36 +496,6 @@ onMounted(async () => {
   background-color: rgba(164, 216, 152, 0.24);
 }
 
-/* ==================== 每日任务入口 ==================== */
-.reminder-row {
-  display: flex;
-  align-items: center;
-  padding: 14px 16px;
-}
-
-.reminder-text {
-  font-size: 15px;
-  font-weight: 600;
-  color: #1f2937;
-}
-
-.reminder-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  flex-shrink: 0;
-  background-color: #ef4444;
-}
-
-.row-arrow {
-  font-size: 16px;
-  color: #c4d0cb;
-}
-
-.reminder-hover {
-  background-color: rgba(164, 216, 152, 0.12);
-}
-
 /* ==================== 快捷入口 ==================== */
 .entry-card {
   display: flex;
@@ -515,7 +503,7 @@ onMounted(async () => {
   align-items: center;
   justify-content: center;
   padding: 14px 4px 12px;
-  border-radius: 16px;
+  border-radius: 12px;
 }
 
 .entry-label {
@@ -624,4 +612,50 @@ onMounted(async () => {
   font-size: 13px;
   color: #9ca3af;
 }
+
+/* ==================== 行业知识(独立卡片:封面+标题) ==================== */
+.knowledge-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.knowledge-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: 16px;
+  box-shadow: 0 14px 40px -10px rgba(54, 143, 111, 0.22);
+  overflow: hidden;
+}
+
+.knowledge-cover {
+  height: 120px;
+  overflow: hidden;
+  background-color: rgba(164, 216, 152, 0.12);
+}
+
+.knowledge-cover-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.knowledge-cover-icon {
+  font-size: 40px;
+  color: #368f6f;
+}
+
+.knowledge-card-title {
+  display: block;
+  padding: 12px 14px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.knowledge-card-hover {
+  background-color: rgba(164, 216, 152, 0.1);
+}
 </style>

+ 182 - 13
src/components/home/DispatchHome.vue

@@ -43,7 +43,7 @@
             @click="goToTaskList"
           >
             <view class="stat-bar"></view>
-            <text class="stat-value">{{ taskStore.total }}</text>
+            <text class="stat-value">{{ todayTasks.length }}</text>
             <text class="stat-label">今日任务</text>
           </view>
           <view
@@ -55,7 +55,7 @@
           >
             <view class="stat-bar"></view>
             <text class="stat-value">{{ taskStore.pendingCount }}</text>
-            <text class="stat-label">待排班</text>
+            <text class="stat-label">待审核</text>
           </view>
           <view
             class="stat-card"
@@ -65,8 +65,8 @@
             @click="goToEmergencyTeam"
           >
             <view class="stat-bar"></view>
-            <text class="stat-value">{{ emergencyCount }}</text>
-            <text class="stat-label">应急中</text>
+            <text class="stat-value">{{ approvedCount }}</text>
+            <text class="stat-label">待安排</text>
           </view>
         </view>
       </view>
@@ -157,6 +157,62 @@
           </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>
+          <text class="section-more" @click="goToNoticeList">更多</text>
+        </view>
+        <view
+          v-for="notice in notices"
+          :key="notice.noticeId"
+          class="list-row"
+          hover-class="row-hover"
+          :hover-start-time="0"
+          :hover-stay-time="120"
+          @click="goToNoticeDetail(notice.noticeId)"
+        >
+          <view class="dot mr-3" :class="notice.priority >= 2 ? 'dot-active' : 'dot-muted'"></view>
+          <text class="flex-1 row-title truncate mr-2">{{ notice.title }}</text>
+          <text class="row-date flex-shrink-0">{{ formatDate(notice.publishTime, 'YYYY-MM-DD') }}</text>
+        </view>
+        <view v-if="notices.length === 0" class="list-row">
+          <text style="font-size: 12px; color: #9ca3af;">暂无通知</text>
+        </view>
+      </view>
+
+      <!-- 行业知识 -->
+      <view>
+        <view class="section-head px-4">
+          <view class="section-bar mr-2"></view>
+          <text class="section-title flex-1">行业知识</text>
+          <text class="section-more" @click="goToKnowledgeList">更多</text>
+        </view>
+        <view class="knowledge-list px-4 pb-4">
+          <view
+            v-for="item in knowledgeList"
+            :key="item.id"
+            class="knowledge-card"
+            hover-class="knowledge-card-hover"
+            :hover-start-time="0"
+            :hover-stay-time="120"
+            @click="goToKnowledgeDetail(item.id)"
+          >
+            <view v-if="item.cover" class="knowledge-cover">
+              <image :src="item.cover" mode="aspectFill" class="w-full h-full" />
+            </view>
+            <view v-else class="knowledge-cover knowledge-cover-placeholder">
+              <text class="uni-icons uniui-chart knowledge-cover-icon"></text>
+            </view>
+            <text class="knowledge-card-title truncate">{{ item.title }}</text>
+          </view>
+          <view v-if="knowledgeList.length === 0" class="py-6 text-center">
+            <text class="empty-text">暂无知识内容</text>
+          </view>
+        </view>
+      </view>
     </view>
   </view>
 </template>
@@ -166,6 +222,9 @@ import { ref, computed, onMounted } from 'vue'
 import { useAuthStore } from '@/stores/auth'
 import { useTaskStore } from '@/stores/task'
 import { useNotificationStore } from '@/stores/notification'
+import { formatDate } from '@/utils'
+import { getNoticeList } from '@/api/notice'
+import { getKnowledgeList, type KnowledgeItem } from '@/api/knowledge'
 import StatusBar from '@/components/common/StatusBar.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 
@@ -173,14 +232,25 @@ const authStore = useAuthStore()
 const taskStore = useTaskStore()
 const notificationStore = useNotificationStore()
 
+const today = formatDate(new Date(), 'YYYY-MM-DD')
+
 const userName = ref(authStore.user?.name || '调度员')
 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))
+function dateHit(dateStr: string, planDate?: string, planEndDate?: string) {
+  if (!planDate) return false
+  if (dateStr === planDate) return true
+  if (planEndDate && dateStr >= planDate && dateStr <= planEndDate) return true
+  return false
+}
+
+const approvedCount = computed(() => taskStore.tasks.filter(t => t.status === 'approved').length)
+const todayTasks = computed(() => taskStore.tasks.filter(t => dateHit(today, t.planDate, t.planEndDate) || t.scheduleDate === today || t.serviceDate === today))
+const pendingTasks = computed(() => taskStore.tasks.filter(t => t.status === 'auditing').slice(0, 3))
 
 function taskIdOf(task: any): string {
-  return String(task?.id ?? task?.taskId ?? '')
+  const id = task?.id ?? task?.taskId ?? ''
+  return id && !Number.isNaN(id) ? String(id) : ''
 }
 
 const greetText = computed(() => {
@@ -197,7 +267,7 @@ function goToTaskList() {
 }
 
 function goToTaskDetail(taskId: string) {
-  if (!taskId) return
+  if (!taskId || taskId === 'NaN') return
   uni.navigateTo({ url: `/subPackages/pages-dispatch/taskDetail?id=${taskId}` })
 }
 
@@ -221,9 +291,40 @@ function goToNotification() {
   uni.navigateTo({ url: '/subPackages/pages-common/messageList' })
 }
 
-onMounted(() => {
-  taskStore.fetchTasks()
+function goToNoticeList() {
+  uni.navigateTo({ url: '/subPackages/pages-dispatch/noticeList' })
+}
+
+function goToNoticeDetail(noticeId: number) {
+  uni.navigateTo({ url: `/subPackages/pages-dispatch/noticeDetail?id=${noticeId}` })
+}
+
+function goToKnowledgeList() {
+  uni.navigateTo({ url: '/subPackages/pages-common/knowledgeList' })
+}
+
+function goToKnowledgeDetail(id: number) {
+  uni.navigateTo({ url: `/subPackages/pages-common/knowledgeDetail?id=${id}` })
+}
+
+const notices = ref<any[]>([])
+const knowledgeList = ref<KnowledgeItem[]>([])
+
+onMounted(async () => {
+  taskStore.fetchMyTasks(today)
   notificationStore.fetchUnreadCount()
+  try {
+    const res = await getNoticeList(1, 3)
+    notices.value = res.rows || []
+  } catch (error) {
+    console.error('获取通知列表失败:', error)
+  }
+  try {
+    const res = await getKnowledgeList(1, 2)
+    knowledgeList.value = res.rows || []
+  } catch (error) {
+    console.error('获取知识列表失败:', error)
+  }
 })
 </script>
 
@@ -366,7 +467,7 @@ onMounted(() => {
   backdrop-filter: blur(14px);
   -webkit-backdrop-filter: blur(14px);
   border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
+  border-radius: 16px;
   box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
   overflow: hidden;
   margin-bottom: 12px;
@@ -381,7 +482,7 @@ onMounted(() => {
   justify-content: center;
   min-height: 88px;
   padding: 14px 6px 12px;
-  border-radius: 16px;
+  border-radius: 12px;
   background-color: rgba(164, 216, 152, 0.14);
   border: 1px solid rgba(164, 216, 152, 0.3);
   overflow: hidden;
@@ -423,7 +524,7 @@ onMounted(() => {
   align-items: center;
   justify-content: center;
   padding: 14px 4px 12px;
-  border-radius: 16px;
+  border-radius: 12px;
 }
 
 .entry-label {
@@ -502,12 +603,80 @@ onMounted(() => {
   color: #9ca3af;
 }
 
+.row-date {
+  font-size: 12px;
+  color: #9ca3af;
+}
+
 .row-hover {
   background-color: rgba(164, 216, 152, 0.1);
 }
 
+/* 通知圆点 */
+.dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.dot-active {
+  background-color: #368f6f;
+  box-shadow: 0 0 0 3px rgba(164, 216, 152, 0.3);
+}
+
+.dot-muted {
+  background-color: #c4d0cb;
+}
+
 .empty-text {
   font-size: 13px;
   color: #9ca3af;
 }
+
+/* ==================== 行业知识(独立卡片:封面+标题) ==================== */
+.knowledge-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.knowledge-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: 16px;
+  box-shadow: 0 14px 40px -10px rgba(54, 143, 111, 0.22);
+  overflow: hidden;
+}
+
+.knowledge-cover {
+  height: 120px;
+  overflow: hidden;
+  background-color: rgba(164, 216, 152, 0.12);
+}
+
+.knowledge-cover-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.knowledge-cover-icon {
+  font-size: 40px;
+  color: #368f6f;
+}
+
+.knowledge-card-title {
+  display: block;
+  padding: 12px 14px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.knowledge-card-hover {
+  background-color: rgba(164, 216, 152, 0.1);
+}
 </style>

+ 79 - 25
src/components/home/SalesHome.vue

@@ -142,12 +142,12 @@
         <view v-else>
           <view
             v-for="task in latestTasks"
-            :key="task.id"
+            :key="taskIdOf(task)"
             class="list-row"
             hover-class="row-hover"
             :hover-start-time="0"
             :hover-stay-time="120"
-            @click="goToTaskDetail(task.id)"
+            @click="goToTaskDetail(taskIdOf(task))"
           >
             <view class="icon-chip mr-3">
               <text class="uni-icons uniui-flag chip-glyph"></text>
@@ -187,32 +187,34 @@
       </view>
 
       <!-- 行业知识 -->
-      <view class="glass-card">
-        <view class="section-head">
+      <view>
+        <view class="section-head px-4">
           <view class="section-bar mr-2"></view>
           <text class="section-title flex-1">行业知识</text>
           <text class="section-more" @click="goToKnowledgeList">更多</text>
         </view>
-        <view
-          v-for="item in knowledgeList"
-          :key="item.id"
-          class="list-row"
-          hover-class="row-hover"
-          :hover-start-time="0"
-          :hover-stay-time="120"
-          @click="goToKnowledgeDetail(item.id)"
-        >
-          <view class="icon-chip mr-3">
-            <text class="uni-icons uniui-chart chip-glyph"></text>
+        <view class="knowledge-list px-4 pb-4">
+          <view
+            v-for="item in knowledgeList"
+            :key="item.id"
+            class="knowledge-card"
+            hover-class="knowledge-card-hover"
+            :hover-start-time="0"
+            :hover-stay-time="120"
+            @click="goToKnowledgeDetail(item.id)"
+          >
+            <view v-if="item.cover" class="knowledge-cover">
+              <image :src="item.cover" mode="aspectFill" class="w-full h-full" />
+            </view>
+            <view v-else class="knowledge-cover knowledge-cover-placeholder">
+              <text class="uni-icons uniui-chart knowledge-cover-icon"></text>
+            </view>
+            <text class="knowledge-card-title truncate">{{ item.title }}</text>
           </view>
-          <view class="flex-1 min-w-0">
-            <text class="row-title truncate">{{ item.title }}</text>
-            <text class="row-sub truncate">{{ summaryOf(item.content) }}</text>
+          <view v-if="knowledgeList.length === 0" class="py-6 text-center">
+            <text class="empty-text">暂无知识内容</text>
           </view>
         </view>
-        <view v-if="knowledgeList.length === 0" class="list-row">
-          <text style="font-size: 12px; color: #9ca3af;">暂无知识内容</text>
-        </view>
       </view>
     </view>
   </view>
@@ -227,7 +229,7 @@ import { useNotificationStore } from '@/stores/notification'
 import StatusBar from '@/components/common/StatusBar.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import { getNoticeList } from '@/api/notice'
-import { getKnowledgeList, summaryOf, type KnowledgeItem } from '@/api/knowledge'
+import { getKnowledgeList, type KnowledgeItem } from '@/api/knowledge'
 import { formatDate } from '@/utils'
 
 const authStore = useAuthStore()
@@ -240,6 +242,11 @@ const notificationCount = computed(() => notificationStore.unreadCount)
 
 const latestTasks = computed(() => taskStore.tasks.slice(0, 3))
 
+function taskIdOf(task: any): string {
+  const id = task?.id ?? task?.taskId ?? ''
+  return id && !Number.isNaN(id) ? String(id) : ''
+}
+
 const greetText = computed(() => {
   const hour = new Date().getHours()
   if (hour < 6) return '凌晨好'
@@ -262,6 +269,7 @@ function goToTaskList() {
 }
 
 function goToTaskDetail(taskId: string) {
+  if (!taskId || taskId === 'NaN') return
   taskStore.setCurrentTask(taskId)
   uni.navigateTo({ url: `/subPackages/pages-common/taskDetail?id=${taskId}` })
 }
@@ -456,7 +464,7 @@ onMounted(async () => {
   backdrop-filter: blur(14px);
   -webkit-backdrop-filter: blur(14px);
   border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
+  border-radius: 16px;
   box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
   overflow: hidden;
   margin-bottom: 12px;
@@ -471,7 +479,7 @@ onMounted(async () => {
   justify-content: center;
   min-height: 88px;
   padding: 14px 6px 12px;
-  border-radius: 16px;
+  border-radius: 12px;
   background-color: rgba(164, 216, 152, 0.14);
   border: 1px solid rgba(164, 216, 152, 0.3);
   overflow: hidden;
@@ -513,7 +521,7 @@ onMounted(async () => {
   align-items: center;
   justify-content: center;
   padding: 14px 4px 12px;
-  border-radius: 16px;
+  border-radius: 12px;
 }
 
 .entry-label {
@@ -658,4 +666,50 @@ onMounted(async () => {
   font-size: 13px;
   color: #9ca3af;
 }
+
+/* ==================== 行业知识(独立卡片:封面+标题) ==================== */
+.knowledge-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.knowledge-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: 16px;
+  box-shadow: 0 14px 40px -10px rgba(54, 143, 111, 0.22);
+  overflow: hidden;
+}
+
+.knowledge-cover {
+  height: 120px;
+  overflow: hidden;
+  background-color: rgba(164, 216, 152, 0.12);
+}
+
+.knowledge-cover-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.knowledge-cover-icon {
+  font-size: 40px;
+  color: #368f6f;
+}
+
+.knowledge-card-title {
+  display: block;
+  padding: 12px 14px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.knowledge-card-hover {
+  background-color: rgba(164, 216, 152, 0.1);
+}
 </style>

+ 2 - 2
src/components/my/ProfileView.vue

@@ -340,7 +340,7 @@ async function handleLogout() {
   backdrop-filter: blur(14px);
   -webkit-backdrop-filter: blur(14px);
   border: 1px solid rgba(164, 216, 152, 0.35);
-  border-radius: 24px;
+  border-radius: 16px;
   box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
   overflow: hidden;
 }
@@ -392,7 +392,7 @@ async function handleLogout() {
   align-items: center;
   min-height: 60px;
   padding: 13px 14px;
-  border-radius: 16px;
+  border-radius: 12px;
 }
 
 .menu-icon {

+ 3 - 3
src/components/task/ConstructionTaskCard.vue

@@ -21,11 +21,11 @@
     <!-- 信息行 -->
     <view class="info-list">
       <view class="info-row">
-        <text class="uni-icons uniui-tools info-icon"></text>
+        <text class="uni-icons uniui-gear-filled info-icon"></text>
         <text class="info-text">{{ serviceTypeText }}</text>
       </view>
       <view class="info-row">
-        <text class="uni-icons uniui-clock-filled info-icon"></text>
+        <text class="uni-icons uniui-calendar-filled info-icon"></text>
         <text class="info-text">服务时间:{{ serviceTimeText }}</text>
       </view>
       <view class="info-row">
@@ -91,7 +91,7 @@ const projectNameText = computed(() => {
 })
 
 const coopTagText = computed(() => {
-  return getCooperationTypeText(props.task.coopType)
+  return getCooperationTypeText(props.task.coopType || (props.task as any).cooperationType)
 })
 
 const urgentText = computed(() => {

+ 40 - 23
src/components/task/TaskListView.vue

@@ -2,7 +2,7 @@
   <view class="app-container pb-24">
     <TopBar :title="pageTitle" />
 
-    <!-- 工具栏:搜索 + 状态下拉 + 新增 -->
+    <!-- 工具栏:搜索 + 新增 -->
     <view class="toolbar px-4 py-3 sticky top-0 z-40 flex items-center gap-2">
       <view class="search-box flex-1 min-w-0 flex items-center rounded-2xl px-4 py-2" :class="{ 'search-focus': focusedSearch }">
         <text class="uni-icons uniui-search search-icon mr-2 text-base"></text>
@@ -16,12 +16,6 @@
           @blur="focusedSearch = false"
         />
       </view>
-      <picker mode="selector" :range="filterLabels" :value="currentFilterIndex" @change="onFilterChange">
-        <view class="status-picker picker-box flex items-center justify-between rounded-2xl px-3 py-2">
-          <text class="text-sm text-gray-800 truncate mr-1">{{ currentFilterLabel }}</text>
-          <text class="uni-icons uniui-arrowdown picker-arrow text-xs flex-shrink-0"></text>
-        </view>
-      </picker>
       <view
         v-if="config.publishPath && role !== 'dispatch'"
         class="add-btn w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0"
@@ -34,6 +28,21 @@
       </view>
     </view>
 
+    <!-- 状态筛选标签(类似项目库) -->
+    <scroll-view scroll-x class="filter-bar" :show-scrollbar="false" enhanced>
+      <view class="flex items-center gap-2 px-4 py-3">
+        <view
+          v-for="(s, i) in filterStatuses"
+          :key="s.value"
+          class="filter-tab flex-shrink-0 rounded-full px-4 py-1.5 text-sm border"
+          :class="i === currentFilterIndex ? 'filter-tab--active' : 'filter-tab--normal'"
+          @click="onFilterTabClick(i)"
+        >
+          {{ s.label }}
+        </view>
+      </view>
+    </scroll-view>
+
     <!-- 加载/空态 -->
     <view class="px-4 mt-3">
       <view v-if="taskStore.loading" class="py-10 text-center">
@@ -134,12 +143,10 @@ const roleConfig: Record<string, { filterStatuses: { value: string; label: strin
 
 const config = computed(() => roleConfig[props.role] || roleConfig.sales)
 const filterStatuses = computed(() => config.value.filterStatuses)
-const filterLabels = computed(() => filterStatuses.value.map(s => s.label))
 const currentFilterIndex = computed(() => {
   const idx = filterStatuses.value.findIndex(s => s.value === currentFilter.value)
   return idx >= 0 ? idx : 0
 })
-const currentFilterLabel = computed(() => filterStatuses.value[currentFilterIndex.value]?.label || '全部')
 const detailPath = computed(() => config.value.detailPath)
 
 const pageTitle = computed(() => {
@@ -175,8 +182,8 @@ const filteredTasks = computed(() => {
   return list
 })
 
-function onFilterChange(e: any) {
-  const item = filterStatuses.value[Number(e.detail.value)]
+function onFilterTabClick(index: number) {
+  const item = filterStatuses.value[index]
   if (item) currentFilter.value = item.value
 }
 
@@ -214,9 +221,8 @@ defineExpose({ refresh })
   border-bottom: 1px solid rgba(164, 216, 152, 0.28);
 }
 
-/* 搜索框 / 状态下拉:默认白底浅灰描边,聚焦时青山绿强调 */
-.search-box,
-.picker-box {
+/* 搜索框:默认白底浅灰描边,聚焦时青山绿强调 */
+.search-box {
   background-color: #ffffff;
   border: 1px solid #e5e7eb;
   transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
@@ -232,15 +238,6 @@ defineExpose({ refresh })
 .search-ph {
   color: #9ca3af;
 }
-.picker-arrow {
-  color: #368f6f;
-}
-
-/* 状态下拉:固定宽度,避免挤占搜索框与新增按钮 */
-.status-picker {
-  width: 104px;
-  flex-shrink: 0;
-}
 
 /* 新增按钮:青山绿→流水青渐变 + 柔绿阴影 */
 .add-btn {
@@ -255,4 +252,24 @@ defineExpose({ refresh })
 .loading-text {
   color: #6b7280;
 }
+
+.filter-bar {
+  background-color: rgba(246, 251, 249, 0.88);
+  border-bottom: 1px solid rgba(164, 216, 152, 0.20);
+  white-space: nowrap;
+}
+.filter-tab {
+  transition: all 0.15s ease;
+}
+.filter-tab--normal {
+  background-color: #ffffff;
+  border-color: #e5e7eb;
+  color: #4b5563;
+}
+.filter-tab--active {
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
+  border-color: transparent;
+  color: #ffffff;
+  box-shadow: 0 6px 14px -8px rgba(54, 143, 111, 0.6);
+}
 </style>

+ 1 - 1
src/config/index.ts

@@ -4,7 +4,7 @@
 // 生产环境通过构建时注入 VITE_APP_BASE_URL 覆盖
 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'
+// // 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
 

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

@@ -26,7 +26,7 @@ const role = computed(() => authStore.userRole || 'sales')
 
 onShow(() => {
   // #ifdef H5 || APP-PLUS
-  safeHideTabBar({ animation: false })
+  safeHideTabBar({ animation: false, repeat: 10 })
   // #endif
   // #ifdef MP-WEIXIN
   const pages = getCurrentPages()

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

@@ -20,7 +20,7 @@ const authStore = useAuthStore()
 
 onShow(() => {
   // #ifdef H5 || APP-PLUS
-  safeHideTabBar({ animation: false })
+  safeHideTabBar({ animation: false, repeat: 10 })
   // #endif
   // #ifdef MP-WEIXIN
   const pages = getCurrentPages()

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

@@ -17,7 +17,7 @@ import AppTabBar from '@/components/common/AppTabBar.vue'
 
 onShow(() => {
   // #ifdef H5 || APP-PLUS
-  safeHideTabBar({ animation: false })
+  safeHideTabBar({ animation: false, repeat: 10 })
   // #endif
   // #ifdef MP-WEIXIN
   const pages = getCurrentPages()

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

@@ -38,7 +38,7 @@ onLoad(() => {
 onShow(() => {
   loadTasks()
   // #ifdef H5 || APP-PLUS
-  safeHideTabBar({ animation: false })
+  safeHideTabBar({ animation: false, repeat: 10 })
   // #endif
   // #ifdef MP-WEIXIN
   const pages = getCurrentPages()

+ 10 - 0
src/stores/auth.ts

@@ -4,8 +4,10 @@ import type { User, UserRole } from '../types'
 import { login, logout } from '../api/auth'
 import type { LoginMenu } from '../api/auth'
 import { getTeamSimpleList } from '../api/team'
+import { useNotificationStore } from './notification'
 
 export const useAuthStore = defineStore('auth', () => {
+  const notificationStore = useNotificationStore()
   // State
   const token = ref<string>('')
   const user = ref<User | null>(null)
@@ -102,6 +104,9 @@ export const useAuthStore = defineStore('auth', () => {
     uni.setStorageSync('user', JSON.stringify(userData))
     uni.setStorageSync('roleInfo', JSON.stringify(roleData))
 
+    // 连接 WebSocket 实时通知
+    notificationStore.connectSocket(res.token)
+
     return finalRole
   }
 
@@ -117,6 +122,11 @@ export const useAuthStore = defineStore('auth', () => {
     uni.removeStorageSync('token')
     uni.removeStorageSync('user')
     uni.removeStorageSync('roleInfo')
+
+    // 断开 WebSocket 并清空通知
+    notificationStore.disconnectSocket()
+    notificationStore.messages = []
+    notificationStore.unreadCount = 0
   }
 
   // 兼容旧代码的别名

+ 51 - 0
src/stores/notification.ts

@@ -2,12 +2,15 @@ import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
 import type { Message } from '@/api/notice'
 import { getMessageList, markMessageRead, markAllMessageRead, getUnreadMessageCount } from '@/api/notice'
+import * as websocket from '@/utils/websocket'
+import type { SocketMessage } from '@/utils/websocket'
 
 export const useNotificationStore = defineStore('notification', () => {
   // State
   const messages = ref<Message[]>([])
   const unreadCount = ref(0)
   const loading = ref(false)
+  const connected = ref(false)
 
   // Getters
   const taskUnreadCount = computed(() => messages.value.filter(m => (m.messageType || m.type) === 'task' && (m.isRead === 0 || m.readStatus === 0)).length)
@@ -64,14 +67,62 @@ export const useNotificationStore = defineStore('notification', () => {
     }
   }
 
+  function prependMessage(msg: Message) {
+    messages.value.unshift(msg)
+    unreadCount.value++
+  }
+
+  function handleSocketMessage(data: SocketMessage) {
+    const msg: Message = {
+      messageId: data.businessId ? Number(data.businessId) : undefined,
+      title: data.title || '新消息',
+      content: data.content || '',
+      messageType: data.type || 'task',
+      type: data.type || 'task',
+      senderId: data.senderId,
+      senderName: data.senderName,
+      isRead: 0,
+      readStatus: 0,
+      createTime: data.sendTime || new Date().toISOString(),
+      extraData: data.extra ? JSON.stringify(data.extra) : (data.businessId ? String(data.businessId) : undefined),
+    }
+    prependMessage(msg)
+  }
+
+  function connectSocket(token: string) {
+    if (!token) return
+    websocket.close()
+    websocket.connect(token)
+    websocket.onMessage((data) => {
+      handleSocketMessage(data)
+    })
+    connected.value = true
+  }
+
+  function disconnectSocket() {
+    websocket.close()
+    connected.value = false
+  }
+
+  function ensureConnected(token: string) {
+    if (!websocket.isConnected() && token) {
+      connectSocket(token)
+    }
+  }
+
   return {
     messages,
     unreadCount,
     loading,
+    connected,
     taskUnreadCount,
     loadMessages,
     fetchUnreadCount,
     markAsRead,
     markAllAsRead,
+    connectSocket,
+    disconnectSocket,
+    ensureConnected,
+    prependMessage,
   }
 })

+ 6 - 4
src/stores/task.ts

@@ -18,10 +18,11 @@ export const useTaskStore = defineStore('task', () => {
     return tasks.value.filter(t => t.status === filterStatus.value)
   })
 
-  const pendingCount = computed(() => tasks.value.filter(t => t.status === 'pending').length)
-  const confirmedCount = computed(() => tasks.value.filter(t => t.status === 'confirmed').length)
-  const scheduledCount = computed(() => tasks.value.filter(t => t.status === 'scheduled').length)
-  const todayCount = computed(() => tasks.value.filter(t => ['scheduled', 'departed', 'constructing'].includes(t.status)).length)
+  const pendingCount = computed(() => tasks.value.filter(t => t.status === 'auditing').length)
+  const confirmedCount = computed(() => tasks.value.filter(t => t.status === 'approved').length)
+  const scheduledCount = computed(() => tasks.value.filter(t => t.status === 'assigned').length)
+  const assignedCount = scheduledCount
+  const todayCount = computed(() => tasks.value.filter(t => t.status === 'in_progress').length)
   const processingCount = computed(() => todayCount.value)
   const completedCount = computed(() => tasks.value.filter(t => t.status === 'completed').length)
   const rejectedCount = computed(() => tasks.value.filter(t => t.status === 'rejected').length)
@@ -106,6 +107,7 @@ export const useTaskStore = defineStore('task', () => {
     pendingCount,
     confirmedCount,
     scheduledCount,
+    assignedCount,
     todayCount,
     processingCount,
     completedCount,

+ 202 - 81
src/subPackages/pages-common/knowledgeDetail.vue

@@ -2,31 +2,51 @@
   <view class="sub-page">
     <TopBar title="知识详情" show-back />
 
-    <view class="px-4 pt-3">
-      <view class="glass-card">
-        <!-- 封面 -->
-        <view class="cover">
-          <text class="cover-icon">{{ icon }}</text>
+    <view v-if="knowledge" class="content-wrap">
+      <!-- 封面大图 -->
+      <view class="hero-card">
+        <image v-if="knowledge.cover" :src="knowledge.cover" mode="aspectFill" class="hero-image" />
+        <view v-else class="hero-fallback">
+          <text class="hero-icon">{{ icon }}</text>
         </view>
-
-        <view class="p-4">
-          <text class="notice-title">{{ knowledge?.title || '知识详情' }}</text>
-          <view class="meta-row">
-            <text class="meta-text">{{ knowledge?.createTime ? formatDate(knowledge.createTime, 'YYYY-MM-DD') : '' }}</text>
+        <view class="hero-overlay" />
+        <view class="hero-info">
+          <view class="category-chip">
+            <text class="category-text">{{ categoryName(knowledge.category) }}</text>
           </view>
+          <text class="hero-title">{{ knowledge.title }}</text>
+        </view>
+      </view>
 
-          <view class="content-box">
-            <text class="summary-title">内容</text>
-            <text class="summary-text pre-wrap">{{ knowledge?.content || '暂无内容' }}</text>
-          </view>
+      <!-- 元信息 -->
+      <view class="meta-bar">
+        <view class="meta-item">
+          <text class="uni-icons uniui-calendar-filled meta-icon" />
+          <text class="meta-text">{{ formatDate(knowledge.createTime, 'YYYY-MM-DD') }}</text>
+        </view>
+        <view class="meta-divider" />
+        <view class="meta-item">
+          <text class="uni-icons uniui-eye-filled meta-icon" />
+          <text class="meta-text">{{ knowledge.readCount || 0 }} 阅读</text>
+        </view>
+      </view>
 
-          <view class="meta-line">
-            <text class="meta-label">分类</text>
-            <text class="type-tag">{{ categoryName(knowledge?.category) }}</text>
+      <!-- 正文 -->
+      <view class="article-card">
+        <view v-for="(block, index) in contentBlocks" :key="index" class="block">
+          <view v-if="block.type === 'text'" class="text-block">
+            <rich-text :nodes="block.content" />
+          </view>
+          <view v-else-if="block.type === 'image'" class="image-block">
+            <image :src="block.src" mode="widthFix" class="article-image" @click="previewImage(block.src)" />
           </view>
         </view>
       </view>
     </view>
+
+    <view v-else class="empty-state">
+      <EmptyState message="知识内容不存在" />
+    </view>
   </view>
 </template>
 
@@ -34,9 +54,16 @@
 import { ref, computed } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 import TopBar from '@/components/common/TopBar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
 import { getKnowledgeDetail, categoryName, type KnowledgeItem } from '@/api/knowledge'
 import { formatDate } from '@/utils'
 
+interface ContentBlock {
+  type: 'text' | 'image'
+  content?: string
+  src?: string
+}
+
 const knowledge = ref<KnowledgeItem | null>(null)
 
 const icon = computed(() => {
@@ -48,6 +75,57 @@ const icon = computed(() => {
   return iconMap[knowledge.value?.category || ''] || ''
 })
 
+const contentBlocks = computed<ContentBlock[]>(() => {
+  if (!knowledge.value?.content) return []
+  return splitContent(knowledge.value.content)
+})
+
+function splitContent(html: string): ContentBlock[] {
+  const decoded = html.replace(/\u0026amp;/g, '\u0026')
+  const blocks: ContentBlock[] = []
+  const imgRegex = /<img[^\u003e]+src=["']([^"']+)["'][^\u003e]*>/gi
+  let lastIndex = 0
+  let match: RegExpExecArray | null
+
+  while ((match = imgRegex.exec(decoded)) !== null) {
+    const textBefore = decoded.slice(lastIndex, match.index)
+    if (textBefore.trim().length > 0) {
+      blocks.push({ type: 'text', content: normalizeHtml(textBefore) })
+    }
+    blocks.push({ type: 'image', src: match[1] })
+    lastIndex = imgRegex.lastIndex
+  }
+
+  const textAfter = decoded.slice(lastIndex)
+  if (textAfter.trim().length > 0) {
+    blocks.push({ type: 'text', content: normalizeHtml(textAfter) })
+  }
+
+  return blocks.length > 0 ? blocks : [{ type: 'text', content: normalizeHtml(decoded) }]
+}
+
+function normalizeHtml(html: string): string {
+  return html
+    .replace(/\s+/g, ' ')
+    .replace(/<br\s*\/?>/gi, '<br/>')
+    .replace(/<p>\s*<\/p>/gi, '<p><br/></p>')
+    .trim()
+}
+
+function previewImage(src?: string) {
+  if (!src) return
+  const images: string[] = []
+  contentBlocks.value.forEach((b) => {
+    if (b.type === 'image' && b.src) {
+      images.push(b.src)
+    }
+  })
+  uni.previewImage({
+    current: src,
+    urls: images.length > 0 ? images : [src],
+  })
+}
+
 onLoad(async (options) => {
   if (!options?.id) return
   try {
@@ -60,108 +138,151 @@ onLoad(async (options) => {
 
 <style scoped>
 .sub-page {
-  max-width: 430px;
-  margin: 0 auto;
   min-height: 100vh;
-  background-color: #f6fbf9;
-  padding-bottom: 24px;
+  background: #f4f9f7;
+}
+
+.content-wrap {
+  padding: 16px;
+  padding-bottom: 40px;
 }
 
-.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);
+.hero-card {
+  position: relative;
   border-radius: 24px;
-  box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
   overflow: hidden;
-  margin-bottom: 12px;
+  height: 260px;
+  box-shadow: 0 20px 50px -16px rgba(54, 143, 111, 0.28);
 }
 
-.cover {
-  height: 160px;
+.hero-image,
+.hero-fallback {
+  width: 100%;
+  height: 100%;
+}
+
+.hero-fallback {
   display: flex;
   align-items: center;
   justify-content: center;
-  background: linear-gradient(135deg, rgba(164, 216, 152, 0.35) 0%, rgba(90, 184, 208, 0.25) 100%);
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
 }
 
-.cover-icon {
-  font-size: 48px;
+.hero-icon {
+  font-size: 72px;
+  opacity: 0.95;
 }
 
-.notice-title {
-  display: block;
-  font-size: 17px;
-  font-weight: 700;
-  color: #1f2937;
-  line-height: 1.4;
+.hero-overlay {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 140px;
+  background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.55) 100%);
 }
 
-.meta-row {
-  margin-top: 8px;
-  padding-bottom: 12px;
-  border-bottom: 1px solid rgba(164, 216, 152, 0.18);
+.hero-info {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 20px;
 }
 
-.meta-text {
+.category-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 5px 12px;
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.95);
+  margin-bottom: 10px;
+}
+
+.category-text {
   font-size: 12px;
-  color: #9ca3af;
+  font-weight: 700;
+  color: #368f6f;
 }
 
-.summary-box {
-  margin-top: 14px;
-  padding: 12px;
-  border-radius: 14px;
-  background-color: rgba(164, 216, 152, 0.14);
-  border: 1px solid rgba(164, 216, 152, 0.3);
+.hero-title {
+  display: block;
+  font-size: 22px;
+  font-weight: 700;
+  color: #ffffff;
+  line-height: 1.4;
+  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
 }
 
-.content-box {
-  margin-top: 12px;
+.meta-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16px;
+  margin-top: 16px;
   padding: 12px;
-  border-radius: 14px;
-  background-color: #f9fafb;
-  border: 1px solid #f0f0f0;
+  border-radius: 16px;
+  background: #ffffff;
+  box-shadow: 0 8px 24px -10px rgba(54, 143, 111, 0.18);
 }
 
-.summary-title {
-  display: block;
-  margin-bottom: 6px;
+.meta-item {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.meta-icon {
   font-size: 13px;
-  font-weight: 600;
-  color: #368f6f;
+  color: #9ca3af;
 }
 
-.summary-text {
-  display: block;
+.meta-text {
   font-size: 13px;
-  line-height: 1.8;
-  color: #4b5563;
+  color: #6b7280;
+}
+
+.meta-divider {
+  width: 1px;
+  height: 12px;
+  background: #d1d5db;
+}
+
+.article-card {
+  margin-top: 16px;
+  padding: 22px 18px;
+  border-radius: 24px;
+  background: #ffffff;
+  box-shadow: 0 14px 40px -12px rgba(54, 143, 111, 0.14);
+}
+
+.block {
+  margin-bottom: 16px;
 }
 
-.pre-wrap {
-  white-space: pre-wrap;
+.block:last-child {
+  margin-bottom: 0;
 }
 
-.meta-line {
+.text-block {
+  font-size: 15px;
+  line-height: 1.85;
+  color: #374151;
+}
+
+.image-block {
   display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-top: 14px;
+  justify-content: center;
 }
 
-.meta-label {
-  font-size: 13px;
-  color: #9ca3af;
+.article-image {
+  width: 100%;
+  max-width: 100%;
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
 }
 
-.type-tag {
-  padding: 2px 10px;
-  border-radius: 999px;
-  font-size: 11px;
-  font-weight: 600;
-  color: #368f6f;
-  background-color: rgba(164, 216, 152, 0.24);
+.empty-state {
+  padding-top: 100px;
 }
 </style>

+ 32 - 64
src/subPackages/pages-common/knowledgeList.vue

@@ -11,25 +11,26 @@
         <EmptyState message="暂无知识内容" />
       </view>
 
-      <view v-else class="glass-card">
+      <view v-else class="gap-y-3 px-4">
         <view
           v-for="item in knowledges"
           :key="item.id"
-          class="list-row"
-          hover-class="row-hover"
+          class="knowledge-card"
+          hover-class="card-hover"
           :hover-start-time="0"
           :hover-stay-time="120"
           @click="goToDetail(item)"
         >
-          <view class="icon-chip mr-3">
-            <text class="uni-icons uniui-folder-add-filled chip-glyph"></text>
+          <view v-if="item.cover" class="card-cover">
+            <image :src="item.cover" mode="aspectFill" class="w-full h-full" />
           </view>
-          <view class="flex-1 min-w-0 mr-2">
-            <text class="row-title truncate">{{ item.title }}</text>
-            <text class="row-sub row-clamp">{{ summaryOf(item.content) }}</text>
-            <text class="row-date">{{ formatDate(item.createTime, 'YYYY-MM-DD') }}</text>
+          <view v-else class="card-cover card-cover-placeholder">
+            <text class="uni-icons uniui-folder-add-filled cover-placeholder-icon"></text>
+          </view>
+
+          <view class="p-3">
+            <text class="card-title truncate">{{ item.title }}</text>
           </view>
-          <text class="uni-icons uniui-arrowright row-arrow"></text>
         </view>
       </view>
     </view>
@@ -40,8 +41,7 @@
 import { ref, onMounted } from 'vue'
 import TopBar from '@/components/common/TopBar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
-import { getKnowledgeList, summaryOf, type KnowledgeItem } from '@/api/knowledge'
-import { formatDate } from '@/utils'
+import { getKnowledgeList, type KnowledgeItem } from '@/api/knowledge'
 
 const loading = ref(false)
 const knowledges = ref<KnowledgeItem[]>([])
@@ -72,79 +72,47 @@ onMounted(async () => {
   padding-bottom: 24px;
 }
 
-.glass-card {
+.gap-y-3 {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.knowledge-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);
+  border-radius: 20px;
+  box-shadow: 0 14px 40px -10px rgba(54, 143, 111, 0.22);
   overflow: hidden;
-  margin-bottom: 12px;
 }
 
-.list-row {
-  display: flex;
-  align-items: center;
-  padding: 13px 16px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
-}
-
-.list-row:first-child {
-  border-top: none;
+.card-hover {
+  background-color: rgba(164, 216, 152, 0.1);
 }
 
-.row-hover {
+.card-cover {
+  height: 150px;
+  overflow: hidden;
   background-color: rgba(164, 216, 152, 0.1);
 }
 
-.icon-chip {
-  width: 42px;
-  height: 42px;
-  border-radius: 12px;
-  flex-shrink: 0;
+.card-cover-placeholder {
   display: flex;
   align-items: center;
   justify-content: center;
-  background-color: rgba(164, 216, 152, 0.2);
 }
 
-.chip-glyph {
-  font-size: 20px;
+.cover-placeholder-icon {
+  font-size: 40px;
   color: #368f6f;
 }
 
-.row-title {
+.card-title {
   display: block;
-  font-size: 14px;
-  font-weight: 500;
+  font-size: 15px;
+  font-weight: 600;
   color: #1f2937;
 }
-
-.row-sub {
-  display: block;
-  margin-top: 3px;
-  font-size: 12px;
-  color: #9ca3af;
-}
-
-.row-clamp {
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
-}
-
-.row-date {
-  display: block;
-  margin-top: 3px;
-  font-size: 12px;
-  color: #9ca3af;
-}
-
-.row-arrow {
-  font-size: 16px;
-  color: #c4d0cb;
-  flex-shrink: 0;
-}
 </style>

+ 19 - 6
src/subPackages/pages-common/publishTask.vue

@@ -82,7 +82,7 @@
               <view class="flex-1">
                 <input v-model="contact.name" class="contact-input" placeholder="姓名" />
               </view>
-              <view class="flex-1 mx-2">
+              <view class="flex-1">
                 <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>
@@ -295,7 +295,7 @@
               <view class="flex-1">
                 <input v-model="contact.name" class="contact-input" placeholder="姓名" />
               </view>
-              <view class="flex-1 mx-2">
+              <view class="flex-1">
                 <input v-model="contact.phone" class="contact-input" placeholder="电话" />
               </view>
               <view class="w-16">
@@ -524,26 +524,39 @@ const {
 .contact-row {
   display: flex;
   align-items: center;
-  padding: 10px 12px;
+  padding: 12px;
   border-radius: 12px;
   background-color: #f9fafb;
+  gap: 10px;
 }
 
 .contact-input {
   width: 100%;
-  padding: 8px 10px;
+  min-height: 42px;
+  padding: 10px 12px;
   border-radius: 8px;
   border: 1px solid #e5e7eb;
   background-color: #ffffff;
-  font-size: 13px;
+  font-size: 14px;
+  line-height: 1.4;
   color: #1f2937;
+  box-sizing: border-box;
+}
+
+.contact-input:focus {
+  border-color: #368f6f;
+}
+
+.contact-row .uni-icons.uniui-close {
+  font-size: 18px;
+  padding: 6px;
 }
 
 .add-contact-btn {
   display: flex;
   align-items: center;
   justify-content: center;
-  padding: 10px 0;
+  padding: 12px 0;
   border-radius: 12px;
   border: 1px dashed #a7f3d0;
   background-color: rgba(164, 216, 152, 0.08);

+ 115 - 131
src/subPackages/pages-common/taskDetail.vue

@@ -7,20 +7,58 @@
     </view>
 
     <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 class="flex justify-end mb-3">
+        <view class="toggle-all-btn" @click="toggleAllSections">
+          <text class="uni-icons" :class="allExpanded ? 'uniui-arrow-up' : 'uniui-arrow-down'"></text>
+          <text class="toggle-all-text">{{ allExpanded ? '一键收起' : '一键展示' }}</text>
+        </view>
+      </view>
+
+      <!-- 任务进度 -->
+      <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 flex-1">任务进度</text>
+          <view class="flex items-center">
+            <StatusTag :status="task.status" />
+            <text v-if="isEmergency" class="emergency-tag ml-2">应急</text>
+          </view>
+        </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 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 v-if="project" class="detail-block">
-        <view class="detail-block-header">
+        <view class="detail-block-header" @click="projectExpanded = !projectExpanded">
           <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="header-title flex-1">项目信息</text>
+          <text
+            class="uni-icons uniui-arrow-down header-toggle transition-transform"
+            :class="projectExpanded ? 'rotate-180' : ''"
+          ></text>
+        </view>
+        <view v-if="projectExpanded">
+          <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">
@@ -53,29 +91,30 @@
               :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
+              <text class="text-sm text-gray-800">{{ contact.name }}</text>
+              <text
                 v-if="contact.phone"
-                class="phone-circle"
+                class="text-sm text-blue-500"
                 @click="callPhone(contact.phone)"
-              >
-                <text class="uni-icons uniui-phone-filled phone-icon"></text>
-              </button>
+              >{{ contact.phone }}</text>
             </view>
           </view>
         </view>
+        </view>
       </view>
 
       <!-- 施工信息 -->
       <view class="detail-block">
-        <view class="detail-block-header">
+        <view class="detail-block-header" @click="constructionExpanded = !constructionExpanded">
           <text class="uni-icons uniui-settings-filled header-icon text-green-500 mr-2"></text>
-          <text class="header-title">施工信息</text>
-        </view>
-        <view v-if="serviceTimeText" class="detail-row">
+          <text class="header-title flex-1">施工信息</text>
+          <text
+            class="uni-icons uniui-arrow-down header-toggle transition-transform"
+            :class="constructionExpanded ? 'rotate-180' : ''"
+          ></text>
+        </view>
+        <view v-if="constructionExpanded">
+          <view v-if="serviceTimeText" class="detail-row">
           <text class="detail-label">服务时间</text>
           <text class="detail-value">{{ serviceTimeText }}</text>
         </view>
@@ -181,68 +220,24 @@
           <text class="detail-label mb-1">备注</text>
           <text class="detail-value text-red-500 text-sm text-left">{{ task.remark }}</text>
         </view>
-      </view>
-
-      <!-- 任务进度 -->
-      <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 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 v-if="timelineRecords.length > 0" class="detail-block">
-        <view class="detail-block-header">
+        <view class="detail-block-header" @click="timelineExpanded = !timelineExpanded">
           <text class="uni-icons uniui-reload header-icon text-orange-500 mr-2"></text>
-          <text class="header-title">处理记录</text>
+          <text class="header-title flex-1">处理记录</text>
+          <text
+            class="uni-icons uniui-arrow-down header-toggle transition-transform"
+            :class="timelineExpanded ? 'rotate-180' : ''"
+          ></text>
         </view>
-        <view class="p-4">
+        <view v-if="timelineExpanded" 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>
@@ -269,7 +264,7 @@ import Timeline from '@/components/common/Timeline.vue'
 import type { TimelineRecord } from '@/components/common/Timeline.vue'
 import { getTaskDetail, urgeTask, cancelTask } from '@/api/task'
 import { getProjectDetail } from '@/api/project'
-import { formatDate, formatMoney, getCooperationTypeText, getConstructionTypeText, getFacilityTypeText, getServiceMethodText, getSuggestedVehicleText, getWaterDistanceText, getAcceptanceMethodText, parseJsonArray } from '@/utils'
+import { formatDate, formatMoney, getCooperationTypeText, getConstructionTypeText, getFacilityTypeText, getServiceMethodText, getSuggestedVehicleText, getWaterDistanceText, getAcceptanceMethodText, parseJsonArray, formatTaskLogAction, getTaskLogRole } from '@/utils'
 import type { Project, LocationContact } from '@/types'
 
 const taskStore = useTaskStore()
@@ -278,6 +273,13 @@ const authStore = useAuthStore()
 const task = ref<any>(null)
 const project = ref<Project | null>(null)
 const loading = ref(false)
+const projectExpanded = ref(true)
+const constructionExpanded = ref(true)
+const timelineExpanded = ref(true)
+
+const allExpanded = computed(() => {
+  return projectExpanded.value && constructionExpanded.value && timelineExpanded.value
+})
 
 const extra = computed(() => {
   if (!task.value?.extraData) return {} as any
@@ -428,27 +430,21 @@ const progressWidth = computed(() => {
   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 => ({
-    action: log.operation || log.action || '状态更新',
+    action: formatTaskLogAction(log.operationType || log.action, log.operation),
     time: log.createTime ? formatDate(log.createTime, 'YYYY-MM-DD HH:mm') : '',
-    operator: log.operatorName || '系统',
-    role: log.operatorRole || roleFromOperationType(log.operationType),
+    operator: log.operatorName || log.operator || log.createBy || '系统',
+    role: log.operatorRole || getTaskLogRole(log.operationType || log.action),
   }))
 })
 
-function roleFromOperationType(type: string): string {
-  const map: Record<string, string> = {
-    create: 'sales',
-    audit: 'dispatch',
-    dispatch: 'dispatch',
-    urge: 'sales',
-    cancel: 'sales',
-  }
-  return map[type] || 'construction'
+function toggleAllSections() {
+  const next = !allExpanded.value
+  projectExpanded.value = next
+  constructionExpanded.value = next
+  timelineExpanded.value = next
 }
 
 function maskPhone(phone: string): string {
@@ -535,7 +531,7 @@ async function handleCancel() {
 }
 
 onLoad((options) => {
-  if (options?.id) {
+  if (options?.id && options.id !== 'NaN') {
     loadDetail(Number(options.id))
   } else if (taskStore.currentTask) {
     task.value = taskStore.currentTask
@@ -573,12 +569,6 @@ function loadProject(projectId: number | string | undefined) {
   padding-bottom: 80px;
 }
 
-.status-wrap {
-  display: flex;
-  align-items: center;
-  padding: 12px 0;
-}
-
 .emergency-tag {
   display: inline-block;
   padding: 2px 8px;
@@ -589,6 +579,22 @@ function loadProject(projectId: number | string | undefined) {
   background-color: #fef2f2;
 }
 
+.toggle-all-btn {
+  display: flex;
+  align-items: center;
+  padding: 4px 8px;
+  border-radius: 12px;
+  background-color: #ffffff;
+  border: 1px solid #e5e7eb;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
+}
+
+.toggle-all-text {
+  font-size: 12px;
+  color: #666666;
+  margin-left: 3px;
+}
+
 .bottom-actions {
   position: fixed;
   bottom: 0;
@@ -655,6 +661,20 @@ function loadProject(projectId: number | string | undefined) {
   font-size: 16px;
 }
 
+.header-toggle {
+  font-size: 13px;
+  color: #999999;
+  padding: 4px;
+}
+
+.transition-transform {
+  transition: transform 0.2s ease;
+}
+
+.rotate-180 {
+  transform: rotate(180deg);
+}
+
 .header-title {
   font-size: 14px;
   font-weight: 600;
@@ -841,42 +861,6 @@ function loadProject(projectId: number | string | undefined) {
   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;
-}
-
-.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 {
   font-size: 13px;
   color: #9ca3af;

+ 77 - 27
src/subPackages/pages-construction/taskDetail.vue

@@ -79,7 +79,7 @@
           <text class="uni-icons uniui-flag-filled text-green-500 text-base"></text>
           <text class="text-base font-semibold text-gray-800 flex-1 truncate">{{ taskName }}</text>
           <StatusTag :status="task.status" class="ml-2" />
-          <text class="uni-icons text-gray-400 text-xs ml-2" :class="sections.info ? 'uniui-arrowup' : 'uniui-arrowdown'"></text>
+          <text class="uni-icons text-gray-400 text-xs ml-2" :class="sections.info ? 'uniui-arrow-up' : 'uniui-arrow-down'"></text>
         </view>
         <view v-if="sections.info" class="info-section-content">
           <!-- 项目信息 -->
@@ -333,21 +333,18 @@
         </button>
       </view>
 
-      <!-- 6. 任务日志 -->
-      <view v-if="taskLogs.length > 0" class="log-section">
-        <view class="log-section-title">
-          <text class="uni-icons uniui-calendar text-green-500 text-base mr-2"></text>
-          <text class="text-base font-semibold text-gray-800">任务日志</text>
+      <!-- 6. 处理记录 -->
+      <view v-if="timelineRecords.length > 0" class="log-section">
+        <view class="log-section-title" @click="logExpanded = !logExpanded">
+          <text class="uni-icons uniui-reload text-green-500 text-base mr-2"></text>
+          <text class="text-base font-semibold text-gray-800 flex-1">处理记录</text>
+          <text
+            class="uni-icons text-gray-400 text-xs transition-transform"
+            :class="logExpanded ? 'uniui-arrow-up' : 'uniui-arrow-down'"
+          ></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="flex-1">
-              <text class="text-sm text-gray-800">{{ log.operatorName || '系统' }}</text>
-              <text class="text-sm text-gray-500">{{ log.operation }}</text>
-              <text v-if="log.remark" class="log-remark">{{ log.remark }}</text>
-            </view>
-          </view>
+        <view v-if="logExpanded" class="p-2">
+          <Timeline :records="timelineRecords" />
         </view>
       </view>
     </view>
@@ -363,6 +360,8 @@ import TopBar from '../../components/common/TopBar.vue'
 import StatusTag from '../../components/common/StatusTag.vue'
 import EmptyState from '../../components/common/EmptyState.vue'
 import ImageUploader from '../../components/common/ImageUploader.vue'
+import Timeline from '../../components/common/Timeline.vue'
+import type { TimelineRecord } from '../../components/common/Timeline.vue'
 import {
   formatDate,
   showToast,
@@ -373,9 +372,11 @@ import {
   getAcceptanceMethodText,
   getSuggestedVehicleText,
   getCooperationTypeText,
+  formatTaskLogAction,
+  getTaskLogRole,
 } from '../../utils'
 import { useAuthStore } from '../../stores/auth'
-import { getTaskDetail, executeSopStep, completeTask, updateTask, type ExecuteSopStepData } from '../../api/task'
+import { getTaskDetail, executeSopStep, completeTask, updateTask, addTaskLog, 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'
@@ -407,6 +408,7 @@ const feedbackText = ref('')
 const sections = reactive({
   info: true,
 })
+const logExpanded = ref(true)
 
 const ALL_PHASES = [
   'departure_vehicle',
@@ -424,6 +426,16 @@ const taskActive = computed(() => task.value ? !['completed', 'cancelled', 'reje
 const isPendingConfirm = computed(() => task.value?.status === 'assigned' && currentStep.value === 0)
 const taskLogs = computed(() => task.value?.logList || [])
 
+const timelineRecords = computed<TimelineRecord[]>(() => {
+  const logs: any[] = taskLogs.value
+  return logs.map(log => ({
+    action: formatTaskLogAction(log.operationType || log.action, log.operation),
+    time: log.createTime ? formatDate(log.createTime, 'YYYY-MM-DD HH:mm') : '',
+    operator: log.operatorName || log.operator || log.createBy || '系统',
+    role: log.operatorRole || getTaskLogRole(log.operationType || log.action),
+  }))
+})
+
 const recordSteps = computed(() => RECORD_STEPS)
 
 /** 扁平化照片列表,供共享进度函数使用 */
@@ -729,27 +741,61 @@ async function onReject() {
       status: 'rejected',
       remark: res.content || t.remark || '',
     } as any)
+
+    await addTaskLog(taskId.value, {
+      operationType: 'reject',
+      operation: '拒绝任务',
+      remark: res.content?.trim() || '',
+      operatorId: authStore.user?.id ? Number(authStore.user.id) : undefined,
+      operatorName: authStore.user?.name,
+    })
+
     showToast('已拒绝任务')
     await loadDetail()
   } catch (e) {
     console.error('拒绝任务失败', e)
+    showToast('拒绝任务失败')
   } finally {
     submitting.value = false
   }
 }
 
-function onFeedback() {
-  uni.showModal({
-    title: '反馈问题',
-    editable: true,
-    placeholderText: '请描述遇到的问题,将提交给调度中心处理',
-    confirmText: '提交',
-    success: (res) => {
-      if (res.confirm) {
-        showToast('反馈已提交')
-      }
-    },
+async function onFeedback() {
+  const res = await new Promise<any>((resolve) => {
+    uni.showModal({
+      title: '反馈问题',
+      editable: true,
+      placeholderText: '请描述遇到的问题,将提交给调度中心处理',
+      confirmText: '提交',
+      success: resolve,
+      fail: () => resolve({ confirm: false }),
+    })
   })
+  if (!res.confirm) return
+
+  const content = res.content?.trim()
+  if (!content) {
+    showToast('请输入反馈内容')
+    return
+  }
+
+  submitting.value = true
+  try {
+    await addTaskLog(taskId.value, {
+      operationType: 'feedback',
+      operation: '反馈问题',
+      remark: content,
+      operatorId: authStore.user?.id ? Number(authStore.user.id) : undefined,
+      operatorName: authStore.user?.name,
+    })
+    showToast('反馈已提交')
+    await loadDetail()
+  } catch (e) {
+    console.error('提交反馈失败', e)
+    showToast('反馈提交失败')
+  } finally {
+    submitting.value = false
+  }
 }
 </script>
 
@@ -1259,6 +1305,10 @@ function onFeedback() {
   margin-top: 4px;
 }
 
+.transition-transform {
+  transition: transform 0.2s ease;
+}
+
 /* 表单 */
 .form-textarea {
   width: 100%;

+ 113 - 51
src/subPackages/pages-dispatch/taskDetail.vue

@@ -7,18 +7,29 @@
     </view>
 
     <view v-else-if="task" class="px-4 pt-3">
+      <view class="flex justify-end mb-3">
+        <view class="toggle-all-btn" @click="toggleAllSections">
+          <text class="uni-icons" :class="allExpanded ? 'uniui-arrow-up' : 'uniui-arrow-down'"></text>
+          <text class="toggle-all-text">{{ allExpanded ? '一键收起' : '一键展示' }}</text>
+        </view>
+      </view>
+
       <!-- 基本信息卡片 -->
       <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
+        <view class="section-head-inner mb-3" @click="basicExpanded = !basicExpanded">
           <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" hover-class="urge-btn-hover" :hover-start-time="0" :hover-stay-time="120" @click="handleUrge">
+          <view v-if="canUrge" class="urge-btn ml-2" hover-class="urge-btn-hover" :hover-start-time="0" :hover-stay-time="120" @click.stop="handleUrge">
             <text class="uni-icons uniui-notification urge-icon mr-1"></text>
             <text class="urge-text">催单</text>
           </view>
+          <text
+            class="uni-icons uniui-arrow-down text-gray-400 text-sm transition-transform ml-2"
+            :class="basicExpanded ? 'rotate-180' : ''"
+          ></text>
         </view>
-        <view class="gap-y-3">
+        <view v-if="basicExpanded" class="gap-y-3">
           <view class="flex items-start">
             <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>
@@ -56,11 +67,15 @@
 
       <!-- 项目信息卡片 -->
       <view v-if="project" class="glass-card p-4">
-        <view class="section-head-inner mb-3">
+        <view class="section-head-inner mb-3" @click="projectExpanded = !projectExpanded">
           <view class="section-bar mr-2"></view>
-          <text class="section-title">项目信息</text>
+          <text class="section-title flex-1">项目信息</text>
+          <text
+            class="uni-icons uniui-arrow-down text-gray-400 text-sm transition-transform"
+            :class="projectExpanded ? 'rotate-180' : ''"
+          ></text>
         </view>
-        <view class="gap-y-3">
+        <view v-if="projectExpanded" class="gap-y-3">
           <view class="flex items-start">
             <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>
@@ -76,15 +91,12 @@
           <view v-if="project.primaryContactName" class="flex items-start">
             <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
+              <text class="text-sm text-gray-800">{{ project.primaryContactName }}</text>
+              <text
                 v-if="project.primaryContactPhone"
-                class="phone-circle"
-                hover-class="phone-btn-hover" :hover-start-time="0" :hover-stay-time="120"
+                class="text-sm text-blue-500"
                 @click="callPhone(project.primaryContactPhone)"
-              >
-                <text class="uni-icons uniui-phone-filled phone-icon"></text>
-              </button>
+              >{{ project.primaryContactPhone }}</text>
             </view>
           </view>
         </view>
@@ -92,11 +104,15 @@
 
       <!-- 故障详情卡片 -->
       <view v-if="geoLocationText || faultMedia.length > 0" class="glass-card p-4">
-        <view class="section-head-inner mb-3">
+        <view class="section-head-inner mb-3" @click="faultExpanded = !faultExpanded">
           <view class="section-bar mr-2"></view>
-          <text class="section-title">故障详情</text>
+          <text class="section-title flex-1">故障详情</text>
+          <text
+            class="uni-icons uniui-arrow-down text-gray-400 text-sm transition-transform"
+            :class="faultExpanded ? 'rotate-180' : ''"
+          ></text>
         </view>
-        <view class="gap-y-3">
+        <view v-if="faultExpanded" class="gap-y-3">
           <view v-if="geoLocationText" class="flex items-start">
             <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>
@@ -119,11 +135,15 @@
 
       <!-- 施工信息卡片 -->
       <view class="glass-card p-4">
-        <view class="section-head-inner mb-3">
+        <view class="section-head-inner mb-3" @click="constructionExpanded = !constructionExpanded">
           <view class="section-bar mr-2"></view>
-          <text class="section-title">施工信息</text>
+          <text class="section-title flex-1">施工信息</text>
+          <text
+            class="uni-icons uniui-arrow-down text-gray-400 text-sm transition-transform"
+            :class="constructionExpanded ? 'rotate-180' : ''"
+          ></text>
         </view>
-        <view class="gap-y-3">
+        <view v-if="constructionExpanded" class="gap-y-3">
           <view v-if="faultDescText" class="flex items-start">
             <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>
@@ -280,11 +300,15 @@
 
       <!-- 调度信息(已安排状态显示) -->
       <view v-if="isDispatched" class="glass-card p-4">
-        <view class="section-head-inner mb-3">
+        <view class="section-head-inner mb-3" @click="dispatchExpanded = !dispatchExpanded">
           <view class="section-bar mr-2"></view>
-          <text class="section-title">调度信息</text>
+          <text class="section-title flex-1">调度信息</text>
+          <text
+            class="uni-icons uniui-arrow-down text-gray-400 text-sm transition-transform"
+            :class="dispatchExpanded ? 'rotate-180' : ''"
+          ></text>
         </view>
-        <view class="gap-y-3">
+        <view v-if="dispatchExpanded" class="gap-y-3">
           <view class="flex items-start">
             <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>
@@ -304,27 +328,18 @@
         </view>
       </view>
 
-      <!-- 操作日志 -->
-      <view v-if="taskLogs.length > 0" class="glass-card p-4">
-        <view class="section-head-inner mb-3">
+      <!-- 处理记录 -->
+      <view v-if="timelineRecords.length > 0" class="glass-card p-4">
+        <view class="section-head-inner mb-3" @click="timelineExpanded = !timelineExpanded">
           <view class="section-bar mr-2"></view>
-          <text class="section-title">操作日志</text>
+          <text class="section-title flex-1">处理记录</text>
+          <text
+            class="uni-icons uniui-arrow-down text-gray-400 text-sm transition-transform"
+            :class="timelineExpanded ? 'rotate-180' : ''"
+          ></text>
         </view>
-        <view class="gap-y-4">
-          <view v-for="(log, index) in taskLogs" :key="log.logId || index" class="flex items-start">
-            <view class="flex flex-col items-center mr-3">
-              <view class="log-dot" :class="{ 'log-dot-active': index === 0 }"></view>
-              <view v-if="index < taskLogs.length - 1" class="w-px h-8 bg-gray-200 mt-1"></view>
-            </view>
-            <view class="flex-1 pb-2">
-              <text class="text-sm font-medium text-gray-800">{{ log.operation }}</text>
-              <view class="flex justify-between mt-1">
-                <text class="text-xs text-gray-500">{{ log.operatorName || '系统' }}</text>
-                <text class="text-xs text-gray-400">{{ formatDate(log.createTime, 'MM-DD HH:mm') }}</text>
-              </view>
-              <text v-if="log.remark" class="text-xs text-gray-400 block mt-1">{{ log.remark }}</text>
-            </view>
-          </view>
+        <view v-if="timelineExpanded">
+          <Timeline :records="timelineRecords" />
         </view>
       </view>
     </view>
@@ -339,6 +354,8 @@ import { onLoad } from '@dcloudio/uni-app'
 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 { useAuthStore } from '../../stores/auth'
 import {
   formatDate,
@@ -350,6 +367,8 @@ import {
   getWaterDistanceText,
   getAcceptanceMethodText,
   getCooperationTypeText,
+  formatTaskLogAction,
+  getTaskLogRole,
 } from '../../utils'
 import { getTaskDetail, approveTask, rejectTaskAudit, dispatchTask, urgeTask } from '../../api/task'
 import { getProjectDetail } from '../../api/project'
@@ -367,6 +386,17 @@ const taskId = ref<number>(0)
 
 const showArrangeForm = ref(false)
 const auditRemark = ref('')
+const timelineExpanded = ref(true)
+const basicExpanded = ref(true)
+const projectExpanded = ref(true)
+const faultExpanded = ref(true)
+const constructionExpanded = ref(true)
+const dispatchExpanded = ref(true)
+
+const allExpanded = computed(() => {
+  return basicExpanded.value && projectExpanded.value && faultExpanded.value &&
+    constructionExpanded.value && dispatchExpanded.value && timelineExpanded.value
+})
 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[] }>>({})
@@ -421,7 +451,25 @@ const extra = computed(() => {
   }
 })
 
-const taskLogs = computed(() => task.value?.logList || [])
+const timelineRecords = computed<TimelineRecord[]>(() => {
+  const logs: any[] = task.value?.logList || []
+  return logs.map(log => ({
+    action: formatTaskLogAction(log.operationType || log.action, log.operation),
+    time: log.createTime ? formatDate(log.createTime, 'YYYY-MM-DD HH:mm') : '',
+    operator: log.operatorName || log.operator || log.createBy || '系统',
+    role: log.operatorRole || getTaskLogRole(log.operationType || log.action),
+  }))
+})
+
+function toggleAllSections() {
+  const next = !allExpanded.value
+  basicExpanded.value = next
+  projectExpanded.value = next
+  faultExpanded.value = next
+  constructionExpanded.value = next
+  dispatchExpanded.value = next
+  timelineExpanded.value = next
+}
 
 const isPendingAudit = computed(() => ['auditing', 'pending'].includes(task.value?.status))
 const isDispatched = computed(() => ['assigned', 'in_progress', 'completed'].includes(task.value?.status))
@@ -498,8 +546,9 @@ const dispatchedVehicleName = computed(() => {
 })
 
 const auditOperator = computed(() => {
-  const auditLog = taskLogs.value.find((l: any) => l.operationType === 'audit')
-  return auditLog?.operatorName || ''
+  const logs: any[] = task.value?.logList || []
+  const auditLog = logs.find((l: any) => l.operationType === 'audit')
+  return auditLog?.operatorName || auditLog?.operator || auditLog?.createBy || ''
 })
 
 function taskTypeText(type: string): string {
@@ -886,15 +935,28 @@ async function handleUrge() {
   color: #368f6f;
 }
 
-.log-dot {
-  width: 12px;
-  height: 12px;
-  border-radius: 50%;
-  background-color: #c4d0cb;
+.transition-transform {
+  transition: transform 0.2s ease;
 }
 
-.log-dot-active {
-  background: linear-gradient(135deg, #368f6f 0%, #4ba98a 100%);
+.rotate-180 {
+  transform: rotate(180deg);
+}
+
+.toggle-all-btn {
+  display: flex;
+  align-items: center;
+  padding: 4px 8px;
+  border-radius: 12px;
+  background-color: #ffffff;
+  border: 1px solid #e5e7eb;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
+}
+
+.toggle-all-text {
+  font-size: 12px;
+  color: #666666;
+  margin-left: 3px;
 }
 
 .empty-text {

+ 32 - 99
src/subPackages/pages-dispatch/vehicleList.vue

@@ -24,51 +24,23 @@
         <EmptyState message="暂无车辆" />
       </view>
 
-      <view v-else class="glass-card">
+      <view v-else class="gap-y-3">
         <view
           v-for="vehicle in filteredVehicles"
           :key="vehicle.id"
-          class="vehicle-row"
-          hover-class="row-hover"
+          class="vehicle-card"
+          hover-class="vehicle-card-hover"
           :hover-start-time="0"
           :hover-stay-time="120"
           @click="showVehicleDetail(vehicle)"
         >
-          <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-gear-filled chip-glyph"></text>
-              </view>
-              <view class="min-w-0">
-                <text class="row-title truncate">{{ vehicle.plateNumber }}</text>
-                <text class="row-sub truncate">{{ vehicle.type }}</text>
-              </view>
-            </view>
-            <text class="status-tag flex-shrink-0" :class="getStatusTagClass(vehicle.status)">
-              {{ getStatusText(vehicle.status) }}
-            </text>
-          </view>
-
-          <view class="row-divider">
-            <view class="grid grid-cols-2 gap-y-2 gap-x-4">
-              <view class="meta-line">
-                <text class="meta-label">尺寸</text>
-                <text class="meta-value">{{ vehicle.size || '-' }}</text>
-              </view>
-              <view class="meta-line">
-                <text class="meta-label">载重</text>
-                <text class="meta-value">{{ vehicle.loadCapacity || '-' }}</text>
-              </view>
-              <view class="meta-line">
-                <text class="meta-label">容积</text>
-                <text class="meta-value">{{ vehicle.capacity || '-' }}</text>
-              </view>
-              <view class="meta-line">
-                <text class="meta-label">驾驶员</text>
-                <text class="meta-value">{{ vehicle.driver || '未分配' }}</text>
-              </view>
-            </view>
+          <view class="flex-1 min-w-0 mr-3">
+            <text class="plate-number truncate">{{ vehicle.plateNumber }}</text>
+            <text class="vehicle-type truncate">{{ vehicle.type || '-' }}</text>
           </view>
+          <text class="status-tag flex-shrink-0" :class="getStatusTagClass(vehicle.status)">
+            {{ getStatusText(vehicle.status) }}
+          </text>
         </view>
       </view>
     </view>
@@ -159,15 +131,10 @@ onPullDownRefresh(async () => {
   padding-bottom: 24px;
 }
 
-.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;
+.gap-y-3 {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
 }
 
 /* 筛选条:默认白底灰边,激活态绿色 */
@@ -194,47 +161,35 @@ onPullDownRefresh(async () => {
   border-color: transparent;
 }
 
-.vehicle-row {
-  padding: 14px 16px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
-}
-
-.vehicle-row:first-child {
-  border-top: none;
-}
-
-.row-hover {
-  background-color: rgba(164, 216, 152, 0.1);
-}
-
-.icon-chip {
-  width: 42px;
-  height: 42px;
-  border-radius: 12px;
-  flex-shrink: 0;
+.vehicle-card {
   display: flex;
   align-items: center;
-  justify-content: center;
-  background-color: rgba(164, 216, 152, 0.2);
-}
-
-.chip-glyph {
-  font-size: 20px;
-  color: #368f6f;
+  justify-content: space-between;
+  padding: 16px;
+  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: 16px;
+  box-shadow: 0 14px 40px -10px rgba(54, 143, 111, 0.22);
 }
 
-.row-title {
+.plate-number {
   display: block;
-  font-size: 14px;
-  font-weight: 600;
+  font-size: 18px;
+  font-weight: 700;
   color: #1f2937;
 }
 
-.row-sub {
+.vehicle-type {
   display: block;
-  margin-top: 3px;
+  margin-top: 4px;
   font-size: 12px;
-  color: #9ca3af;
+  color: #6b7280;
+}
+
+.vehicle-card-hover {
+  background-color: rgba(164, 216, 152, 0.1);
 }
 
 .status-tag {
@@ -264,28 +219,6 @@ onPullDownRefresh(async () => {
   background-color: #f3f4f6;
 }
 
-.row-divider {
-  margin-top: 12px;
-  padding-top: 12px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
-}
-
-.meta-line {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-}
-
-.meta-label {
-  font-size: 12px;
-  color: #9ca3af;
-}
-
-.meta-value {
-  font-size: 13px;
-  color: #4b5563;
-}
-
 .empty-text {
   font-size: 13px;
   color: #9ca3af;

+ 77 - 22
src/subPackages/pages-dispatch/visualization.vue

@@ -38,14 +38,14 @@
         </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 flex-1">任务分布</text>
+          <text class="section-title flex-1">任务时段统计</text>
           <text class="realtime-tag">图表</text>
         </view>
-        <ChartView type="pie" :data="taskPieData" :height="200" />
+        <ChartView type="ring" :canvasId="'task-time-chart'" :data="taskTimeData" :height="240" />
       </view>
 
       <!-- 任务趋势 -->
@@ -55,7 +55,7 @@
           <text class="section-title flex-1">任务趋势</text>
           <text class="realtime-tag">图表</text>
         </view>
-        <ChartView type="line" :data="taskTrendData" :height="200" />
+        <ChartView type="line" :canvasId="'task-trend-chart'" :data="taskTrendData" :height="220" />
       </view>
 
       <!-- 车辆统计 -->
@@ -133,36 +133,91 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import TopBar from '../../components/common/TopBar.vue'
 import { getDashboardStats } from '../../api/dashboard'
+import { getTaskList } from '../../api/task'
+import { formatDate } from '../../utils'
 
 import ChartView from '../../components/common/ChartView.vue'
 
 const loading = ref(true)
 const stats = ref<Record<string, any>>({})
+const allTasks = ref<any[]>([])
 
-const taskPieData = ref([
-  { label: '已完成', value: 42, color: '#34c759' },
-  { label: '进行中', value: 15, color: '#4f8ef7' },
-  { label: '待处理', value: 8, color: '#ff9500' },
-  { label: '已取消', value: 3, color: '#8e8e93' },
-])
-
-const taskTrendData = ref([
-  { label: '周一', value: 5 },
-  { label: '周二', value: 8 },
-  { label: '周三', value: 6 },
-  { label: '周四', value: 12 },
-  { label: '周五', value: 10 },
-  { label: '周六', value: 15 },
-  { label: '周日', value: 9 },
-])
+const taskTimeData = computed(() => {
+  const periods = [
+    { label: '凌晨', color: '#8e8e93' },
+    { label: '上午', color: '#34c759' },
+    { label: '下午', color: '#4f8ef7' },
+    { label: '晚上', color: '#ff9500' },
+    { label: '未设置', color: '#d1d5db' },
+  ]
+
+  return periods
+    .map((period) => ({
+      ...period,
+      value: allTasks.value.filter((t: any) => getTaskPeriod(t) === period.label).length,
+    }))
+    .filter((item) => item.value > 0)
+})
+
+function getTaskPeriod(task: any): string {
+  const range = task.planTimeRange
+  if (range) {
+    const map: Record<string, string> = {
+      morning: '上午',
+      afternoon: '下午',
+      evening: '晚上',
+      night: '晚上',
+      dawn: '凌晨',
+      early_morning: '凌晨',
+    }
+    if (map[range]) return map[range]
+  }
+
+  const timeStr = task.scheduleTime || task.serviceTime || task.expectedArrivalTime
+  if (!timeStr || typeof timeStr !== 'string') return '未设置'
+
+  const match = timeStr.match(/(\d{1,2}):\d{2}/)
+  if (!match) return '未设置'
+
+  const hour = parseInt(match[1], 10)
+  if (hour >= 0 && hour < 6) return '凌晨'
+  if (hour >= 6 && hour < 12) return '上午'
+  if (hour >= 12 && hour < 18) return '下午'
+  if (hour >= 18 && hour < 24) return '晚上'
+  return '未设置'
+}
+
+const taskTrendData = computed(() => {
+  const days = 7
+  const result = []
+  for (let i = days - 1; i >= 0; i--) {
+    const date = new Date()
+    date.setDate(date.getDate() - i)
+    const dateStr = formatDate(date, 'YYYY-MM-DD')
+    const label = formatDate(date, 'MM-DD')
+    const count = allTasks.value.filter((t: any) => {
+      const taskDate = t.planDate || t.scheduleDate || t.serviceDate || t.createTime
+      if (!taskDate) return false
+      const normalized = formatDate(taskDate, 'YYYY-MM-DD')
+      return normalized === dateStr
+    }).length
+    result.push({ label, value: count })
+  }
+  return result
+})
 
 onMounted(async () => {
+  loading.value = true
   try {
-    const res = await getDashboardStats()
+    const [res, taskRes] = await Promise.all([
+      getDashboardStats(),
+      getTaskList(1, 1000),
+    ])
     stats.value = res
+    allTasks.value = taskRes.rows || []
   } catch (error) {
     console.error('获取统计数据失败:', error)
   } finally {

+ 204 - 99
src/subPackages/pages-sales/knowledgeDetail.vue

@@ -2,49 +2,130 @@
   <view class="sub-page">
     <TopBar title="知识详情" show-back />
 
-    <view v-if="knowledge" class="px-4 pt-3">
-      <!-- 知识标题 -->
-      <view class="glass-card p-5">
-        <text class="notice-title">{{ knowledge.title }}</text>
-        <view class="flex items-center mt-3 gap-4">
-          <text class="type-tag">{{ categoryName(knowledge.category) }}</text>
-          <view class="flex items-center">
-            <text class="uni-icons uniui-calendar-filled meta-icon mr-1"></text>
-            <text class="meta-text">{{ formatDate(knowledge.createTime) }}</text>
-          </view>
-          <view class="flex items-center">
-            <text class="uni-icons uniui-eye-filled meta-icon mr-1"></text>
-            <text class="meta-text">{{ knowledge.readCount }}</text>
+    <view v-if="knowledge" class="content-wrap">
+      <!-- 封面大图 -->
+      <view class="hero-card">
+        <image v-if="knowledge.cover" :src="knowledge.cover" mode="aspectFill" class="hero-image" />
+        <view v-else class="hero-fallback">
+          <text class="hero-icon">{{ categoryIcon(knowledge.category) }}</text>
+        </view>
+        <view class="hero-overlay" />
+        <view class="hero-info">
+          <view class="category-chip">
+            <text class="category-text">{{ categoryName(knowledge.category) }}</text>
           </view>
+          <text class="hero-title">{{ knowledge.title }}</text>
+        </view>
+      </view>
+
+      <!-- 元信息 -->
+      <view class="meta-bar">
+        <view class="meta-item">
+          <text class="uni-icons uniui-calendar-filled meta-icon" />
+          <text class="meta-text">{{ formatDate(knowledge.createTime) }}</text>
+        </view>
+        <view class="meta-divider" />
+        <view class="meta-item">
+          <text class="uni-icons uniui-eye-filled meta-icon" />
+          <text class="meta-text">{{ knowledge.readCount || 0 }} 阅读</text>
         </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="article-card">
+        <view v-for="(block, index) in contentBlocks" :key="index" class="block">
+          <view v-if="block.type === 'text'" class="text-block">
+            <rich-text :nodes="block.content" />
+          </view>
+          <view v-else-if="block.type === 'image'" class="image-block">
+            <image :src="block.src" mode="widthFix" class="article-image" @click="previewImage(block.src)" />
+          </view>
         </view>
-        <text class="body-p">{{ knowledge.content }}</text>
       </view>
     </view>
 
-    <view v-else class="py-20 text-center">
+    <view v-else class="empty-state">
       <EmptyState message="知识内容不存在" />
     </view>
   </view>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 import TopBar from '../../components/common/TopBar.vue'
 import EmptyState from '../../components/common/EmptyState.vue'
 import { getKnowledgeDetail, categoryName, type KnowledgeItem } from '../../api/knowledge'
 import { formatDate } from '../../utils'
 
+interface ContentBlock {
+  type: 'text' | 'image'
+  content?: string
+  src?: string
+}
+
 const knowledge = ref<KnowledgeItem | null>(null)
 
+const contentBlocks = computed<ContentBlock[]>(() => {
+  if (!knowledge.value?.content) return []
+  return splitContent(knowledge.value.content)
+})
+
+function categoryIcon(category?: string): string {
+  const iconMap: Record<string, string> = {
+    industry: '',
+    safety: '️',
+    equipment: '',
+  }
+  return iconMap[category || ''] || ''
+}
+
+function splitContent(html: string): ContentBlock[] {
+  const decoded = html.replace(/\u0026amp;/g, '\u0026')
+  const blocks: ContentBlock[] = []
+  const imgRegex = /<img[^\u003e]+src=["']([^"']+)["'][^\u003e]*>/gi
+  let lastIndex = 0
+  let match: RegExpExecArray | null
+
+  while ((match = imgRegex.exec(decoded)) !== null) {
+    const textBefore = decoded.slice(lastIndex, match.index)
+    if (textBefore.trim().length > 0) {
+      blocks.push({ type: 'text', content: normalizeHtml(textBefore) })
+    }
+    blocks.push({ type: 'image', src: match[1] })
+    lastIndex = imgRegex.lastIndex
+  }
+
+  const textAfter = decoded.slice(lastIndex)
+  if (textAfter.trim().length > 0) {
+    blocks.push({ type: 'text', content: normalizeHtml(textAfter) })
+  }
+
+  return blocks.length > 0 ? blocks : [{ type: 'text', content: normalizeHtml(decoded) }]
+}
+
+function normalizeHtml(html: string): string {
+  return html
+    .replace(/\s+/g, ' ')
+    .replace(/<br\s*\/?>/gi, '<br/>')
+    .replace(/<p>\s*<\/p>/gi, '<p><br/></p>')
+    .trim()
+}
+
+function previewImage(src?: string) {
+  if (!src) return
+  const images: string[] = []
+  contentBlocks.value.forEach((b) => {
+    if (b.type === 'image' && b.src) {
+      images.push(b.src)
+    }
+  })
+  uni.previewImage({
+    current: src,
+    urls: images.length > 0 ? images : [src],
+  })
+}
+
 onLoad(async (options) => {
   if (!options?.id) return
   try {
@@ -57,127 +138,151 @@ onLoad(async (options) => {
 
 <style scoped>
 .sub-page {
-  max-width: 430px;
-  margin: 0 auto;
   min-height: 100vh;
-  background-color: #f6fbf9;
-  padding-bottom: 24px;
+  background: #f4f9f7;
+}
+
+.content-wrap {
+  padding: 16px;
+  padding-bottom: 40px;
 }
 
-.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);
+.hero-card {
+  position: relative;
   border-radius: 24px;
-  box-shadow: 0 18px 50px -12px rgba(54, 143, 111, 0.28);
   overflow: hidden;
-  margin-bottom: 12px;
+  height: 260px;
+  box-shadow: 0 20px 50px -16px rgba(54, 143, 111, 0.28);
 }
 
-.notice-title {
-  display: block;
-  font-size: 17px;
-  font-weight: 700;
-  color: #1f2937;
-  line-height: 1.4;
+.hero-image,
+.hero-fallback {
+  width: 100%;
+  height: 100%;
 }
 
-.type-tag {
-  padding: 2px 10px;
+.hero-fallback {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #368f6f 0%, #5ab8d0 100%);
+}
+
+.hero-icon {
+  font-size: 72px;
+  opacity: 0.95;
+}
+
+.hero-overlay {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 140px;
+  background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.55) 100%);
+}
+
+.hero-info {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 20px;
+}
+
+.category-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 5px 12px;
   border-radius: 999px;
-  font-size: 11px;
-  font-weight: 600;
-  color: #368f6f;
-  background-color: rgba(164, 216, 152, 0.24);
+  background: rgba(255, 255, 255, 0.95);
+  margin-bottom: 10px;
 }
 
-.meta-icon {
+.category-text {
   font-size: 12px;
-  color: #9ca3af;
+  font-weight: 700;
+  color: #368f6f;
 }
 
-.meta-text {
-  font-size: 12px;
-  color: #9ca3af;
+.hero-title {
+  display: block;
+  font-size: 22px;
+  font-weight: 700;
+  color: #ffffff;
+  line-height: 1.4;
+  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
 }
 
-.section-head {
+.meta-bar {
   display: flex;
   align-items: center;
-  padding: 16px 16px 12px;
+  justify-content: center;
+  gap: 16px;
+  margin-top: 16px;
+  padding: 12px;
+  border-radius: 16px;
+  background: #ffffff;
+  box-shadow: 0 8px 24px -10px rgba(54, 143, 111, 0.18);
 }
 
-.section-head-inner {
+.meta-item {
   display: flex;
   align-items: center;
+  gap: 5px;
 }
 
-.section-bar {
-  width: 4px;
-  height: 16px;
-  border-radius: 2px;
-  background: linear-gradient(180deg, #368f6f 0%, #5ab8d0 100%);
+.meta-icon {
+  font-size: 13px;
+  color: #9ca3af;
 }
 
-.section-title {
-  font-size: 16px;
-  font-weight: 700;
-  color: #1f2937;
+.meta-text {
+  font-size: 13px;
+  color: #6b7280;
 }
 
-.body-p {
-  display: block;
-  font-size: 14px;
-  line-height: 1.8;
-  color: #4b5563;
-  white-space: pre-wrap;
+.meta-divider {
+  width: 1px;
+  height: 12px;
+  background: #d1d5db;
 }
 
-.list-row {
-  display: flex;
-  align-items: center;
-  padding: 13px 16px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
+.article-card {
+  margin-top: 16px;
+  padding: 22px 18px;
+  border-radius: 24px;
+  background: #ffffff;
+  box-shadow: 0 14px 40px -12px rgba(54, 143, 111, 0.14);
 }
 
-.row-title {
-  display: block;
-  font-size: 14px;
-  font-weight: 500;
-  color: #1f2937;
+.block {
+  margin-bottom: 16px;
 }
 
-.row-sub {
-  display: block;
-  margin-top: 3px;
-  font-size: 12px;
-  color: #9ca3af;
+.block:last-child {
+  margin-bottom: 0;
 }
 
-.row-hover {
-  background-color: rgba(164, 216, 152, 0.1);
+.text-block {
+  font-size: 15px;
+  line-height: 1.85;
+  color: #374151;
 }
 
-.row-arrow {
-  font-size: 16px;
-  color: #c4d0cb;
-  flex-shrink: 0;
+.image-block {
+  display: flex;
+  justify-content: center;
 }
 
-.icon-chip {
-  width: 42px;
-  height: 42px;
+.article-image {
+  width: 100%;
+  max-width: 100%;
   border-radius: 12px;
-  flex-shrink: 0;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background-color: rgba(164, 216, 152, 0.2);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
 }
 
-.chip-glyph {
-  font-size: 20px;
-  color: #368f6f;
+.empty-state {
+  padding-top: 100px;
 }
 </style>

+ 35 - 60
src/subPackages/pages-sales/knowledgeList.vue

@@ -34,30 +34,25 @@
         <EmptyState message="暂无知识内容" />
       </view>
 
-      <view v-else class="glass-card">
+      <view v-else class="gap-y-3">
         <view
           v-for="item in filteredKnowledges"
           :key="item.id"
-          class="notice-row"
-          hover-class="row-hover"
+          class="knowledge-card"
+          hover-class="card-hover"
           :hover-start-time="0"
           :hover-stay-time="120"
           @click="goToDetail(item.id)"
         >
-          <view class="flex items-center justify-between mb-2">
-            <text class="row-title truncate flex-1 min-w-0 mr-2">{{ item.title }}</text>
-            <text class="type-tag flex-shrink-0">{{ categoryName(item.category) }}</text>
+          <view v-if="item.cover" class="card-cover">
+            <image :src="item.cover" mode="aspectFill" class="w-full h-full" />
           </view>
-          <text class="row-sub notice-content">{{ summaryOf(item.content) }}</text>
-          <view class="flex items-center justify-between mt-2">
-            <view class="flex items-center">
-              <text class="uni-icons uniui-calendar-filled meta-icon mr-1"></text>
-              <text class="row-date">{{ formatDate(item.createTime) }}</text>
-            </view>
-            <view class="flex items-center">
-              <text class="uni-icons uniui-eye-filled meta-icon mr-1"></text>
-              <text class="row-date">{{ item.readCount }}</text>
-            </view>
+          <view v-else class="card-cover card-cover-placeholder">
+            <text class="uni-icons uniui-folder-add-filled cover-placeholder-icon"></text>
+          </view>
+
+          <view class="p-3">
+            <text class="card-title truncate">{{ item.title }}</text>
           </view>
         </view>
       </view>
@@ -72,11 +67,8 @@ import EmptyState from '../../components/common/EmptyState.vue'
 import {
   getKnowledgeList,
   getKnowledgeCategories,
-  categoryName,
-  summaryOf,
   type KnowledgeItem,
 } from '../../api/knowledge'
-import { formatDate } from '../../utils'
 
 const loading = ref(false)
 const searchText = ref('')
@@ -179,65 +171,48 @@ onMounted(async () => {
   border-color: transparent;
 }
 
-.glass-card {
+.gap-y-3 {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.knowledge-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);
+  border-radius: 20px;
+  box-shadow: 0 14px 40px -10px rgba(54, 143, 111, 0.22);
   overflow: hidden;
-  margin-bottom: 12px;
-}
-
-.notice-row {
-  padding: 14px 16px;
-  border-top: 1px solid rgba(164, 216, 152, 0.18);
 }
 
-.notice-row:first-child {
-  border-top: none;
-}
-
-.row-hover {
+.card-hover {
   background-color: rgba(164, 216, 152, 0.1);
 }
 
-.row-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #1f2937;
-}
-
-.row-sub {
-  font-size: 12px;
-  color: #9ca3af;
-}
-
-.notice-content {
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
+.card-cover {
+  height: 150px;
   overflow: hidden;
 }
 
-.row-date {
-  font-size: 12px;
-  color: #9ca3af;
+.card-cover-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, rgba(164, 216, 152, 0.35) 0%, rgba(90, 184, 208, 0.25) 100%);
 }
 
-.meta-icon {
-  font-size: 12px;
-  color: #9ca3af;
+.cover-placeholder-icon {
+  font-size: 40px;
+  color: #368f6f;
 }
 
-.type-tag {
-  padding: 2px 10px;
-  border-radius: 999px;
-  font-size: 11px;
+.card-title {
+  display: block;
+  font-size: 15px;
   font-weight: 600;
-  color: #368f6f;
-  background-color: rgba(164, 216, 152, 0.24);
+  color: #1f2937;
 }
 
 .empty-text {

+ 58 - 3
src/utils/index.ts

@@ -63,10 +63,12 @@ export function chooseMediaCompat(options: ChooseMediaOptions): void {
 /**
  * 安全隐藏原生 tabBar
  * 非 tabBar 页面调用会抛错,静默处理避免阻塞业务流程
+ * App-PLUS 端原生 tabBar 可能延迟渲染,默认多 retry 几次
  */
-export function safeHideTabBar(options?: { animation?: boolean; repeat?: number }): void {
+export function safeHideTabBar(options?: { animation?: boolean; repeat?: number; interval?: number }): void {
   const animation = options?.animation ?? false
-  const repeat = options?.repeat ?? 3
+  const repeat = options?.repeat ?? 8
+  const interval = options?.interval ?? 120
 
   function hideOnce() {
     try {
@@ -82,7 +84,7 @@ export function safeHideTabBar(options?: { animation?: boolean; repeat?: number
   hideOnce()
   if (repeat > 1) {
     for (let i = 1; i < repeat; i++) {
-      setTimeout(hideOnce, i * 100)
+      setTimeout(hideOnce, i * interval)
     }
   }
 }
@@ -703,3 +705,56 @@ export function parseExtraData<T = Record<string, any>>(value?: string | T | nul
   }
 }
 
+/**
+ * 将任务日志操作类型/内容转换为展示文本
+ */
+export function formatTaskLogAction(operationType: string | undefined, operation: string | undefined): string {
+  if (!operationType && !operation) return '状态更新'
+
+  // 优先使用 operation 中的中文描述(后端已映射)
+  if (operation && !operation.startsWith('执行SOP步骤')) {
+    return operation
+  }
+
+  // SOP 步骤:"执行SOP步骤: xxx,动作: execute" → "完成:xxx"
+  if (operation && operation.startsWith('执行SOP步骤')) {
+    const match = operation.match(/执行SOP步骤:\s*([^,]+)/)
+    if (match) return `完成:${match[1].trim()}`
+  }
+
+  const map: Record<string, string> = {
+    create: '创建任务',
+    audit: '审核任务',
+    dispatch: '任务分配',
+    confirm: '确认接单',
+    depart: '出车出发',
+    arrive: '到达现场',
+    work: '开始施工',
+    complete: '完成任务',
+    clean: '现场清理',
+    accept: '客户验收',
+    finish: '任务结案',
+    urge: '催单',
+    cancel: '取消任务',
+    sop_next: 'SOP推进',
+    sop_prev: 'SOP回退',
+    departure_prep: '出车准备',
+    site_survey: '现场勘察',
+  }
+  return map[operationType || ''] || operation || operationType || '状态更新'
+}
+
+/**
+ * 根据任务日志操作类型判断角色
+ */
+export function getTaskLogRole(operationType: string | undefined): string {
+  const map: Record<string, string> = {
+    create: 'sales',
+    audit: 'dispatch',
+    dispatch: 'dispatch',
+    urge: 'sales',
+    cancel: 'sales',
+  }
+  return map[operationType || ''] || 'construction'
+}
+

+ 205 - 0
src/utils/websocket.ts

@@ -0,0 +1,205 @@
+import { BASE_URL } from '../config'
+
+export interface SocketMessage {
+  type?: string
+  title?: string
+  content?: string
+  businessId?: number
+  senderId?: number
+  senderName?: string
+  targetType?: string
+  targetRole?: string
+  targetUserId?: number
+  targetTeamId?: number
+  sendTime?: string
+  extra?: any
+  [key: string]: any
+}
+
+type MessageHandler = (message: SocketMessage) => void
+
+const HEARTBEAT_INTERVAL = 30000 // 心跳间隔 30s
+const HEARTBEAT_TIMEOUT = 10000 // 心跳超时 10s
+const MAX_RECONNECT_DELAY = 30000 // 最大重连间隔 30s
+
+let socketTask: UniApp.SocketTask | null = null
+let messageHandlers: MessageHandler[] = []
+let heartbeatTimer: number | null = null
+let heartbeatTimeoutTimer: number | null = null
+let reconnectTimer: number | null = null
+let reconnectAttempts = 0
+let isManualClose = false
+let currentUrl = ''
+
+function buildWebSocketUrl(token: string): string {
+  const wsUrl = BASE_URL.replace(/^http/, 'ws').replace(/\/$/, '')
+  return `${wsUrl}/ws/notify?token=${encodeURIComponent(token)}`
+}
+
+function resetHeartbeat() {
+  if (heartbeatTimer) {
+    clearInterval(heartbeatTimer)
+    heartbeatTimer = null
+  }
+  if (heartbeatTimeoutTimer) {
+    clearTimeout(heartbeatTimeoutTimer)
+    heartbeatTimeoutTimer = null
+  }
+}
+
+function startHeartbeat() {
+  resetHeartbeat()
+  heartbeatTimer = setInterval(() => {
+    if (!socketTask) return
+    try {
+      socketTask.send({ data: JSON.stringify({ type: 'ping' }) })
+      heartbeatTimeoutTimer = setTimeout(() => {
+        console.warn('WebSocket 心跳超时,准备重连')
+        reconnect()
+      }, HEARTBEAT_TIMEOUT)
+    } catch (e) {
+      console.error('发送心跳失败', e)
+      reconnect()
+    }
+  }, HEARTBEAT_INTERVAL)
+}
+
+function scheduleReconnect() {
+  if (isManualClose) return
+  if (reconnectTimer) return
+
+  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY)
+  reconnectAttempts++
+
+  reconnectTimer = setTimeout(() => {
+    reconnectTimer = null
+    reconnect()
+  }, delay)
+}
+
+function reconnect() {
+  if (isManualClose || !currentUrl) return
+  console.log(`WebSocket 尝试重连... (第${reconnectAttempts}次)`)
+  close()
+  connect(currentUrl)
+}
+
+export function connect(token: string) {
+  if (socketTask) {
+    console.log('WebSocket 已连接,跳过')
+    return
+  }
+
+  // 清空旧的消息处理器,避免重复订阅
+  messageHandlers = []
+
+  const url = buildWebSocketUrl(token)
+  currentUrl = url
+  isManualClose = false
+
+  console.log('WebSocket 连接中...', url)
+
+  socketTask = uni.connectSocket({
+    url,
+    success: () => {
+      console.log('WebSocket connectSocket 调用成功')
+    },
+    fail: (err) => {
+      console.error('WebSocket connectSocket 调用失败', err)
+      socketTask = null
+      scheduleReconnect()
+    },
+  })
+
+  socketTask.onOpen(() => {
+    console.log('WebSocket 连接已打开')
+    reconnectAttempts = 0
+    startHeartbeat()
+  })
+
+  socketTask.onMessage((res) => {
+    try {
+      const data = JSON.parse(res.data as string) as SocketMessage
+      // 处理心跳响应
+      if (data.type === 'pong' || data.type === 'connected') {
+        if (heartbeatTimeoutTimer) {
+          clearTimeout(heartbeatTimeoutTimer)
+          heartbeatTimeoutTimer = null
+        }
+        return
+      }
+      messageHandlers.forEach((handler) => {
+        try {
+          handler(data)
+        } catch (e) {
+          console.error('WebSocket 消息处理器异常', e)
+        }
+      })
+    } catch (e) {
+      console.warn('WebSocket 收到非JSON消息', res.data)
+    }
+  })
+
+  socketTask.onClose(() => {
+    console.log('WebSocket 连接已关闭')
+    socketTask = null
+    resetHeartbeat()
+    scheduleReconnect()
+  })
+
+  socketTask.onError((err) => {
+    console.error('WebSocket 错误', err)
+    socketTask = null
+    resetHeartbeat()
+    scheduleReconnect()
+  })
+}
+
+export function send(data: any) {
+  if (!socketTask) {
+    console.warn('WebSocket 未连接,无法发送消息')
+    return
+  }
+  socketTask.send({
+    data: typeof data === 'string' ? data : JSON.stringify(data),
+  })
+}
+
+export function close() {
+  isManualClose = true
+  resetHeartbeat()
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+  if (socketTask) {
+    try {
+      socketTask.close({})
+    } catch (e) {
+      console.error('关闭 WebSocket 失败', e)
+    }
+    socketTask = null
+  }
+}
+
+export function onMessage(handler: MessageHandler) {
+  messageHandlers.push(handler)
+  return () => {
+    messageHandlers = messageHandlers.filter((h) => h !== handler)
+  }
+}
+
+export function isConnected(): boolean {
+  return socketTask !== null
+}
+
+export function resetReconnectAttempts() {
+  reconnectAttempts = 0
+}
+
+// 网络恢复或 App 回到前台时尝试重连
+export function ensureConnected(token: string) {
+  if (!isConnected() && !isManualClose) {
+    connect(token)
+  }
+}

文件差異過大導致無法顯示
+ 0 - 0
users.json


部分文件因文件數量過多而無法顯示