Ver Fonte

完成工单管理,预警管理

zengy há 4 dias atrás
pai
commit
5cf0d5995a

+ 3 - 3
src/components/ActionFormDrawer.vue

@@ -80,7 +80,7 @@ function isFutureDateTime(value: string) {
 function submit() {
   const content = text.value.trim()
   if (props.domain === 'workorder') {
-    if (props.action === '派单' && (!handlerId.value || !reviewerId.value)) return invalid('请选择责任人和核验人')
+    if (props.action === '派单' && (!handlerId.value || !reviewerId.value)) return invalid('请选择处置人和核验人')
     if (props.action === '派单' && dateTime.value && !isFutureDateTime(dateTime.value)) return invalid('计划完成时间必须晚于当前时间')
     if (['核查接单','工单处置','开始处理','取消工单','提交验收'].includes(props.action) && !content) return invalid('请填写操作内容')
     if (['核查接单','提交验收'].includes(props.action) && !files.value.length) return invalid('请上传现场佐证材料')
@@ -111,8 +111,8 @@ function submit() {
       <view class="drawer-head"><text>{{ title }}</text><text class="close" @tap="close">×</text></view>
       <scroll-view class="drawer-body" scroll-y>
         <template v-if="action === '派单'">
-          <FormField v-model="person" label="责任人" type="select" required @select="pickUser('handler')" />
-          <FormField v-model="reviewer" label="核验人(默认当前账户,可选择其他)" type="select" required @select="pickUser('reviewer')" />
+          <FormField v-model="person" label="处置人" type="select" required @select="pickUser('handler')" />
+          <FormField v-model="reviewer" label="核验人" type="select" required @select="pickUser('reviewer')" />
           <FormField v-model="remark" label="派单备注" type="textarea" placeholder="可填写故障要点、现场注意事项等派单说明" />
           <FormField v-model="dateTime" label="计划完成时间" type="datetime" future-only placeholder="请选择" />
         </template>

+ 4 - 3
src/components/BusinessCard.vue

@@ -11,12 +11,12 @@ const splitNumber = computed(() => props.idLabel === '工单编号' || props.idL
   <view class="business-card card" @tap="$emit('open', item)">
     <view v-if="splitNumber" class="split-body">
       <view class="number-row"><view class="number-main"><text>{{ idLabel }}</text><text>{{ item.id }}</text></view><view class="status-group"><text class="status-pill" :class="item.tone">{{ item.status }}</text></view></view>
-      <view class="info-column"><view v-for="field in item.fields" :key="field.label" class="field-row"><text class="label">{{ field.label }}</text><text class="value">{{ field.value }}</text></view></view>
+      <view class="info-column"><view v-for="field in item.fields" :key="field.label" class="field-row"><text class="label">{{ field.label }}</text><text class="value" :class="{ clamp: field.label === '预警内容' }">{{ field.value }}</text></view></view>
     </view>
     <template v-else>
       <view class="card-head"><view class="title-wrap"><text class="dot"></text><text class="title">{{ item.title }}</text></view><view class="status-group"><text class="status-pill" :class="item.tone">{{ item.status }}</text></view></view>
       <view class="number"><text>{{ idLabel || '编号' }}</text><text>{{ item.id }}</text></view>
-      <view v-for="field in item.fields" :key="field.label" class="field-row"><text class="label">{{ field.label }}</text><text class="value">{{ field.value }}</text></view>
+      <view v-for="field in item.fields" :key="field.label" class="field-row"><text class="label">{{ field.label }}</text><text class="value" :class="{ clamp: field.label === '预警内容' }">{{ field.value }}</text></view>
     </template>
     <view class="card-foot"><text v-if="item.secondaryStatus" class="status-pill foot-status" :class="item.secondaryTone">{{ item.secondaryStatus }}</text><text>{{ item.actionLabel || detailLabel }}</text><text class="arrow">›</text></view>
   </view>
@@ -24,7 +24,7 @@ const splitNumber = computed(() => props.idLabel === '工单编号' || props.idL
 
 <style scoped lang="scss">
 .business-card { padding: 25rpx 25rpx 0; margin-bottom: 20rpx; overflow: hidden; }
-.split-body{display:block}.number-row{display:flex;align-items:center;justify-content:space-between;gap:16rpx;padding-bottom:18rpx;margin-bottom:2rpx;border-bottom:2rpx dashed #cdd7e2}.number-main{display:flex;align-items:center;gap:20rpx;min-width:0}.number-main text:first-child{flex-shrink:0;color:#8b98a8;font-size:23rpx}.number-main text:last-child{color:#08264c;font-size:25rpx;font-weight:700;line-height:1.55;word-break:break-all}.status-group{display:flex;flex-shrink:0;align-items:center;justify-content:flex-end;gap:8rpx;flex-wrap:wrap}.info-column{min-width:0}
+.split-body{display:block}.number-row{display:flex;align-items:center;justify-content:space-between;gap:12rpx;padding-bottom:18rpx;margin-bottom:2rpx;border-bottom:2rpx dashed #cdd7e2}.number-main{display:flex;flex:1;align-items:center;gap:14rpx;min-width:0}.number-main text:first-child{flex-shrink:0;color:#8b98a8;font-size:23rpx}.number-main text:last-child{color:#08264c;font-size:22rpx;font-weight:700;line-height:1.55;white-space:nowrap}.status-group{display:flex;flex-shrink:0;align-items:center;justify-content:flex-end;gap:8rpx;flex-wrap:wrap}.info-column{min-width:0}
 .card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; padding-bottom: 18rpx; border-bottom: 1rpx solid #edf1f5; }
 .title-wrap { display: flex; flex: 1; align-items: center; min-width: 0; }
 .dot { flex-shrink: 0; width: 13rpx; height: 13rpx; margin-right: 14rpx; border-radius: 50%; background: #08264c; box-shadow: 0 0 0 7rpx #e7edf3; }
@@ -33,6 +33,7 @@ const splitNumber = computed(() => props.idLabel === '工单编号' || props.idL
 .number { color: #607087; font-size: 25rpx; }
 .label { color: #8b98a8; font-size: 25rpx; }
 .value { color: #43546a; font-size: 25rpx; }
+.value.clamp { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
 .card-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8rpx; height: 74rpx; margin-top: 18rpx; color: #08264c; border-top: 1rpx solid #edf1f5; font-size: 25rpx; }
 .foot-status { margin-right: auto; }
 .arrow { transform: translateY(-2rpx); font-size: 40rpx; font-weight: 300; }

+ 82 - 0
src/components/WarningActionDrawer.vue

@@ -0,0 +1,82 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import FormField from './FormField.vue'
+import UploadPanel from './UploadPanel.vue'
+import type { WarningParticipant } from '@/services/warnings'
+import { useUserStore } from '@/stores/user'
+const user = useUserStore()
+
+const props = withDefaults(defineProps<{
+  modelValue: boolean
+  action: string
+  handlers?: WarningParticipant[]
+  reviewers?: WarningParticipant[]
+  submitting?: boolean
+}>(), { handlers: () => [], reviewers: () => [], submitting: false })
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  submit: [payload: { remark: string; content: string; files: string[]; handler: string; reviewer: string; result: 'CLEAR' | 'RETURN'; opinion: string }]
+}>()
+const remark = ref('')
+const content = ref('')
+const files = ref<string[]>([])
+const handlerIndex = ref(-1)
+const reviewerIndex = ref(-1)
+const participantLabel = (person: WarningParticipant) => `${person.nickName || person.userName}(${person.deptName || person.userName})`
+const handlerLabels = computed(() => ['请选择处置人', ...props.handlers.map(participantLabel)])
+const reviewerLabels = computed(() => ['请选择核验人', ...props.reviewers.map(participantLabel)])
+const result = ref<'CLEAR' | 'RETURN'>('CLEAR')
+const opinion = ref('')
+const title = computed(() => ({ PUBLISH: '发布预警', RECEIVE: '接收预警', START: '开始处置', HANDLE: '提交处置', VERIFY: '预警核验' }[props.action] || '预警操作'))
+
+watch(() => props.modelValue, open => {
+  if (!open) return
+  remark.value = ''; content.value = ''; files.value = []; handlerIndex.value = -1
+  reviewerIndex.value = props.reviewers.findIndex(person => person.userName === user.profile?.userName)
+  result.value = 'CLEAR'; opinion.value = ''
+})
+
+function selectIndex(event: Event, target: 'handler' | 'reviewer') {
+  const index = Number((event as CustomEvent<{ value: string }>).detail.value) - 1
+  if (target === 'handler') handlerIndex.value = index
+  else reviewerIndex.value = index
+}
+
+function submit() {
+  const handler = props.handlers[handlerIndex.value]?.userName || ''
+  const reviewer = props.reviewers[reviewerIndex.value]?.userName || ''
+  if (props.action === 'PUBLISH' && (!handler || !reviewer)) return uni.showToast({ title: '请选择处置人和核验人', icon: 'none' })
+  if (props.action === 'HANDLE' && !content.value.trim()) return uni.showToast({ title: '请填写处置内容', icon: 'none' })
+  if (props.action === 'VERIFY' && !opinion.value.trim()) return uni.showToast({ title: '请填写核验意见', icon: 'none' })
+  emit('submit', { remark: remark.value.trim(), content: content.value.trim(), files: files.value, handler, reviewer, result: result.value, opinion: opinion.value.trim() })
+}
+</script>
+
+<template>
+  <view v-if="modelValue" class="drawer-root">
+    <view class="mask" @tap="!submitting && $emit('update:modelValue', false)"></view>
+    <view class="drawer">
+      <view class="drawer-head"><text>{{ title }}</text><text class="close" @tap="!submitting && $emit('update:modelValue', false)">×</text></view>
+      <scroll-view class="drawer-body" scroll-y>
+        <template v-if="action === 'PUBLISH'">
+          <view class="field"><text class="label"><b>*</b>处置人</text><picker :range="handlerLabels" :value="handlerIndex + 1" :disabled="!handlers.length" @change="selectIndex($event, 'handler')"><view class="select">{{ handlerLabels[handlerIndex + 1] }}<text>⌄</text></view></picker><text v-if="!handlers.length" class="tip">暂无符合条件且拥有预警处置权限的人员</text></view>
+          <view class="field"><text class="label"><b>*</b>核验人</text><picker :range="reviewerLabels" :value="reviewerIndex + 1" :disabled="!reviewers.length" @change="selectIndex($event, 'reviewer')"><view class="select">{{ reviewerLabels[reviewerIndex + 1] }}<text>⌄</text></view></picker><text v-if="!reviewers.length" class="tip">暂无符合条件且拥有预警核验权限的人员</text></view>
+        </template>
+        <FormField v-if="action === 'RECEIVE' || action === 'START'" v-model="remark" label="备注" type="textarea" placeholder="可填写现场情况或操作说明" />
+        <template v-if="action === 'HANDLE'">
+          <FormField v-model="content" label="处置内容" type="textarea" required placeholder="请填写故障原因、处置措施和处理结果" />
+          <view class="field"><text class="label">现场图片</text><UploadPanel v-model="files" :max-count="9" :max-size-mb="20" /><text class="tip">支持 JPG、JPEG、PNG,单张不超过20MB</text></view>
+        </template>
+        <template v-if="action === 'VERIFY'">
+          <view class="field"><text class="label"><b>*</b>核验结果</text><view class="choices"><button :class="{ active: result === 'CLEAR' }" @tap="result = 'CLEAR'">通过并解除</button><button :class="{ active: result === 'RETURN' }" @tap="result = 'RETURN'">退回重办</button></view></view>
+          <FormField v-model="opinion" label="核验意见" type="textarea" required placeholder="请输入现场核验结论和依据" />
+        </template>
+      </scroll-view>
+      <view class="drawer-actions"><button class="cancel" :disabled="submitting" @tap="$emit('update:modelValue', false)">取消</button><button class="submit" :disabled="submitting" @tap="submit">{{ submitting ? '正在提交...' : '确认提交' }}</button></view>
+    </view>
+  </view>
+</template>
+
+<style scoped lang="scss">
+.drawer-root{position:fixed;inset:0;z-index:100}.mask{position:absolute;inset:0;background:rgba(5,18,35,.55)}.drawer{position:absolute;right:0;bottom:0;left:0;overflow:hidden;border-radius:28rpx 28rpx 0 0;background:#fff}.drawer-head{display:flex;align-items:center;justify-content:space-between;height:96rpx;padding:0 30rpx;color:#122b48;border-bottom:1rpx solid #edf1f5;font-size:31rpx;font-weight:700}.close{display:flex;align-items:center;justify-content:center;width:58rpx;height:58rpx;color:#7f8c9b;border-radius:50%;background:#f2f5f8;font-size:42rpx}.drawer-body{max-height:62vh;padding:5rpx 30rpx 25rpx}.field{padding:22rpx 0;border-bottom:1rpx solid #edf1f5}.label{display:block;margin-bottom:16rpx;color:#34485f;font-size:27rpx}.label b{margin-right:6rpx;color:#e34d59}.select{display:flex;align-items:center;justify-content:space-between;height:78rpx;padding:0 20rpx;color:#43566d;border:1rpx solid #dce4ed;border-radius:12rpx;background:#f8fafc;font-size:27rpx}.choices{display:grid;grid-template-columns:1fr 1fr;gap:14rpx}.choices button{height:72rpx;margin:0;color:#52647a;border:1rpx solid #d9e1ea;border-radius:12rpx;background:#fff;font-size:24rpx;line-height:72rpx}.choices button.active{color:#08264c;border-color:#08264c;background:#eef3f8;font-weight:600}.confirm{display:flex;flex-direction:column;margin:25rpx 0;padding:26rpx;color:#2e435b;border-radius:12rpx;background:#f3f7fb;font-size:29rpx;font-weight:650}.confirm text:last-child{margin-top:12rpx;color:#7d8c9e;font-size:23rpx;font-weight:400}.tip{display:block;margin-top:14rpx;color:#97a3b1;font-size:21rpx}.drawer-actions{display:grid;grid-template-columns:1fr 1fr;gap:18rpx;padding:18rpx 26rpx calc(18rpx + env(safe-area-inset-bottom));border-top:1rpx solid #edf1f5}.drawer-actions button{height:80rpx;margin:0;border-radius:999rpx;font-size:27rpx;line-height:80rpx}.cancel{color:#43566d;border:1rpx solid #d7e0e9;background:#fff}.submit{color:#fff;background:#08264c}
+</style>

+ 2 - 2
src/pages.json

@@ -14,7 +14,7 @@
     { "path": "pages/contacts/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
     { "path": "pages/profile/index", "style": { "navigationStyle": "custom" } },
     { "path": "pages/message/workorder-detail", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
-    { "path": "pages/message/warning-detail", "style": { "navigationStyle": "custom" } },
+      { "path": "pages/message/warning-detail", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
     { "path": "pages/message/alarm-detail", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
     { "path": "pages/message/notice-detail", "style": { "navigationStyle": "custom" } },
     { "path": "pages/message/notices", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
@@ -31,7 +31,7 @@
     { "path": "pages/emergency/dispatch", "style": { "navigationStyle": "custom" } },
     { "path": "pages/emergency/audit", "style": { "navigationStyle": "custom" } },
     { "path": "pages/profile/workorders", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
-    { "path": "pages/profile/warnings", "style": { "navigationStyle": "custom" } },
+      { "path": "pages/profile/warnings", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
     { "path": "pages/profile/basic", "style": { "navigationStyle": "custom" } },
     { "path": "pages/profile/password", "style": { "navigationStyle": "custom" } },
     { "path": "pages/profile/login-log", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } }

Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 1
src/pages/message/alarm-detail.vue


+ 61 - 53
src/pages/message/index.vue

@@ -1,98 +1,106 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
-import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
+import { onShow, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
 import SegmentTabs from '@/components/SegmentTabs.vue'
 import BusinessCard from '@/components/BusinessCard.vue'
-import { warnings, type ListItem } from '@/data/mock'
+import type { ListItem } from '@/data/mock'
 import { getNoticeList, getNoticeTypeLabel, type SystemNotice } from '@/services/notices'
 import { getMobilePendingWorkOrders, workOrderStatusText, workOrderTone, type WorkOrder } from '@/services/workorders'
+import { getMobilePendingWarnings, getWarningRevision, warningStatusText, warningStatusTone, type EarlyWarning } from '@/services/warnings'
 import { navigateTo } from '@/utils/navigation'
 
 const active = ref(0)
 const notices = ref<SystemNotice[]>([])
 const pendingOrders = ref<WorkOrder[]>([])
+const pendingWarnings = ref<EarlyWarning[]>([])
 const pendingTotal = ref(0)
+const pendingWarningTotal = ref(0)
 const pendingPageNum = ref(1)
+const warningPageNum = ref(1)
 const pendingLoading = ref(false)
+const warningLoading = ref(false)
 const pendingLoaded = ref(false)
+const warningLoaded = ref(false)
 const pendingHasMore = computed(() => pendingOrders.value.length < pendingTotal.value)
-const list = computed<ListItem[]>(() => active.value === 0 ? pendingOrders.value.map(toCard) : warnings.slice(0, 4))
-const pendingCount = computed(() => pendingTotal.value + warnings.filter(item => item.status !== '已解除').length)
+const warningHasMore = computed(() => pendingWarnings.value.length < pendingWarningTotal.value)
+const list = computed<ListItem[]>(() => active.value === 0 ? pendingOrders.value.map(toCard) : pendingWarnings.value.map(toWarningCard))
+const pendingCount = computed(() => pendingTotal.value + pendingWarningTotal.value)
 
-async function refresh() {
-  await Promise.all([loadPendingOrders(true), loadNotices()])
-}
-
-onLoad(refresh)
-onPullDownRefresh(async () => {
-  await refresh()
-  uni.stopPullDownRefresh()
-})
-onReachBottom(() => {
-  if (active.value === 0) loadPendingOrders()
+async function refresh() { await Promise.all([loadPendingOrders(true), loadPendingWarnings(true), loadNotices()]) }
+let seenWarningRevision = -1
+onShow(() => {
+  const revision = getWarningRevision()
+  if (seenWarningRevision < 0) { seenWarningRevision = revision; refresh() }
+  else if (seenWarningRevision !== revision) { seenWarningRevision = revision; loadPendingWarnings(true) }
 })
+onPullDownRefresh(async () => { await refresh(); uni.stopPullDownRefresh() })
+onReachBottom(() => active.value === 0 ? loadPendingOrders() : loadPendingWarnings())
 
 function toCard(order: WorkOrder): ListItem {
   const delayPending = Number(order.orderStatus) === 3 && Number(order.delayApplyStatus) === 1
   return { id: order.orderNo, title: order.orderDesc || '故障工单', status: workOrderStatusText(order.orderStatus), tone: workOrderTone(order.orderStatus), secondaryStatus: delayPending ? '延期待审核' : undefined, secondaryTone: delayPending ? 'orange' : undefined, fields: [
     ...(delayPending ? [{ label: '延期申请', value: `待审核${order.delayedDeadline ? `(延期至 ${order.delayedDeadline})` : ''}` }] : []),
-    { label: '工单描述', value: order.orderDesc || '—' },
-    { label: '设备信息', value: order.deviceId ? `${order.deviceCode || '—'}${order.deviceName ? `(${order.deviceName})` : ''}` : '未关联设备' },
+    { label: '工单描述', value: order.orderDesc || '—' }, { label: '设备信息', value: order.deviceId ? `${order.deviceCode || '—'}${order.deviceName ? `(${order.deviceName})` : ''}` : '未关联设备' },
     { label: '安装位置', value: order.deviceLocation || '—' },
   ] }
 }
 
+function toWarningCard(item: EarlyWarning): ListItem {
+  return { id: item.warningNo, title: item.warningContent || '预警', status: item.statusLabel || warningStatusText(item.status), tone: warningStatusTone(item.status), actionLabel: '详情', fields: [
+    { label: '预警内容', value: item.warningContent || '—' }, { label: '预警类型', value: item.warningTypeName || item.warningType || '—' },
+    { label: '关联设备', value: item.deviceCode ? `${item.deviceCode}${item.deviceName ? `(${item.deviceName})` : ''}` : '—' },
+  ] }
+}
+
+function updateBadge() {
+  const count = pendingCount.value
+  if (count > 0) uni.setTabBarBadge({ index: 0, text: String(Math.min(count, 99)) })
+  else uni.removeTabBarBadge({ index: 0 })
+}
+
 async function loadPendingOrders(reset = false) {
   if (pendingLoading.value || (!reset && !pendingHasMore.value)) return
-  if (reset) {
-    pendingPageNum.value = 1
-    pendingOrders.value = []
-    pendingTotal.value = 0
-  }
+  if (reset) { pendingPageNum.value = 1; pendingOrders.value = []; pendingTotal.value = 0 }
   pendingLoading.value = true
   try {
     const page = await getMobilePendingWorkOrders({ pageNum: pendingPageNum.value, pageSize: 10 })
     pendingOrders.value = reset ? page.records : pendingOrders.value.concat(page.records)
     pendingTotal.value = Number(page.total) || 0
     if (pendingOrders.value.length < pendingTotal.value) pendingPageNum.value += 1
-    const count = pendingCount.value
-    if (count > 0) uni.setTabBarBadge({ index: 0, text: String(Math.min(count, 99)) })
-    else uni.removeTabBarBadge({ index: 0 })
+    updateBadge()
   } catch (error) {
-    if (reset) {
-      pendingOrders.value = []
-      pendingTotal.value = 0
-    }
+    if (reset) { pendingOrders.value = []; pendingTotal.value = 0 }
     uni.showToast({ title: error instanceof Error ? error.message : '待办工单获取失败', icon: 'none' })
-  } finally {
-    pendingLoading.value = false
-    pendingLoaded.value = true
-  }
+  } finally { pendingLoading.value = false; pendingLoaded.value = true }
 }
 
-async function loadNotices() {
+async function loadPendingWarnings(reset = false) {
+  if (warningLoading.value || (!reset && !warningHasMore.value)) return
+  if (reset) { warningPageNum.value = 1; pendingWarnings.value = []; pendingWarningTotal.value = 0 }
+  warningLoading.value = true
   try {
-    const result = await getNoticeList({ pageNum: 1, pageSize: 10 })
-    notices.value = result.rows.filter(item => item.status !== '1')
+    const page = await getMobilePendingWarnings({ pageNum: warningPageNum.value, pageSize: 10 })
+    pendingWarnings.value = reset ? page.records : pendingWarnings.value.concat(page.records)
+    pendingWarningTotal.value = Number(page.total) || 0
+    if (pendingWarnings.value.length < pendingWarningTotal.value) warningPageNum.value += 1
+    updateBadge()
   } catch (error) {
-    notices.value = []
-    uni.showToast({ title: error instanceof Error ? error.message : '通知公告获取失败', icon: 'none' })
-  }
+    if (reset) { pendingWarnings.value = []; pendingWarningTotal.value = 0 }
+    uni.showToast({ title: error instanceof Error ? error.message : '待办预警获取失败', icon: 'none' })
+  } finally { warningLoading.value = false; warningLoaded.value = true }
 }
 
-function openNotice(item?: SystemNotice) {
-  if (item?.noticeId == null) return
-  navigateTo(`/pages/message/notice-detail?id=${item.noticeId}`)
+async function loadNotices() {
+  try { const result = await getNoticeList({ pageNum: 1, pageSize: 10 }); notices.value = result.rows.filter(item => item.status !== '1') }
+  catch (error) { notices.value = []; uni.showToast({ title: error instanceof Error ? error.message : '通知公告获取失败', icon: 'none' }) }
 }
 
-function openItem(index: number, status: string) {
+function openNotice(item?: SystemNotice) { if (item?.noticeId != null) navigateTo(`/pages/message/notice-detail?id=${item.noticeId}`) }
+function openItem(index: number) {
   if (active.value === 0) navigateTo(`/pages/message/workorder-detail?id=${pendingOrders.value[index]?.orderId}`)
-  else navigateTo(`/pages/message/warning-detail?status=${encodeURIComponent(status)}`)
-}
-
-function openAll() {
-  navigateTo(active.value === 0 ? '/pages/profile/workorders' : '/pages/profile/warnings')
+  else navigateTo(`/pages/message/warning-detail?id=${pendingWarnings.value[index]?.warningId}`)
 }
+function openAll() { navigateTo(active.value === 0 ? '/pages/profile/workorders' : '/pages/profile/warnings') }
 </script>
 
 <template>
@@ -112,10 +120,10 @@ function openAll() {
     </view>
     <SegmentTabs v-model="active" :items="['待办工单', '待办预警']" action-label="查看全部" @action="openAll" />
     <view class="page-content with-tabs message-list">
-      <BusinessCard v-for="(item,index) in list" :key="item.id" :item="item" :id-label="active === 0 ? '工单编号' : '预警编号'" @open="openItem(index, item.status)" />
-      <view v-if="pendingLoaded && !list.length" class="empty-list">暂无待办数据</view>
-      <view v-else-if="active === 0 && pendingLoading" class="load-tip">正在加载...</view>
-      <view v-else-if="active === 0 && pendingOrders.length && !pendingHasMore" class="load-tip">已加载全部</view>
+      <BusinessCard v-for="(item,index) in list" :key="item.id" :item="item" :id-label="active === 0 ? '工单编号' : '预警编号'" detail-label="详情" @open="openItem(index)" />
+      <view v-if="(active === 0 ? pendingLoaded : warningLoaded) && !list.length" class="empty-list">暂无待办数据</view>
+      <view v-else-if="active === 0 ? pendingLoading : warningLoading" class="load-tip">正在加载...</view>
+      <view v-else-if="list.length && !(active === 0 ? pendingHasMore : warningHasMore)" class="load-tip">已加载全部</view>
     </view>
   </view>
 </template>
@@ -124,7 +132,7 @@ function openAll() {
 .hero { padding: calc(36rpx + env(safe-area-inset-top)) 26rpx 28rpx; color: #fff; background: linear-gradient(135deg,#061b34,#174e76); }
 .hero-top { display: flex; align-items: center; justify-content: space-between; padding: 4rpx 5rpx 30rpx; }
 .hello,.sub { display: block; }.hello{font-size:36rpx;font-weight:650}.sub{margin-top:10rpx;color:rgba(255,255,255,.78);font-size:24rpx}
-.notice{display:flex;align-items:center;height:82rpx;padding:0 22rpx;border-radius:16rpx;background:rgba(255,255,255,.16);backdrop-filter:blur(8px)}.bell-icon{flex-shrink:0;width:34rpx;height:34rpx;margin-right:16rpx;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9'/%3E%3Cpath d='M13.7 21a2 2 0 0 1-3.4 0'/%3E%3C/svg%3E") center/contain no-repeat}.notice-swiper{flex:1;height:82rpx;min-width:0}.notice-swiper swiper-item{display:flex;align-items:center}.notice-main{display:flex;flex:1;align-items:center;min-width:0}.notice-label{flex-shrink:0;margin-right:16rpx;padding:4rpx 13rpx;color:#9be2f6;border-radius:99rpx;background:rgba(102,215,248,.14);font-size:22rpx;font-weight:600}.notice-text{overflow:hidden;color:rgba(255,255,255,.9);font-size:24rpx;text-overflow:ellipsis;white-space:nowrap}.empty-notice{color:rgba(255,255,255,.68)}.more{font-size:45rpx}
+.notice{display:flex;align-items:center;height:82rpx;padding:0 22rpx;border-radius:16rpx;background:rgba(255,255,255,.14)}.bell-icon{flex-shrink:0;width:34rpx;height:34rpx;margin-right:16rpx;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9'/%3E%3Cpath d='M13.7 21a2 2 0 0 1-3.4 0'/%3E%3C/svg%3E") center/contain no-repeat}.notice-swiper{flex:1;height:82rpx;min-width:0}.notice-swiper swiper-item{display:flex;align-items:center}.notice-main{display:flex;flex:1;align-items:center;min-width:0}.notice-label{flex-shrink:0;margin-right:16rpx;padding:4rpx 13rpx;color:#9be2f6;border-radius:99rpx;background:rgba(102,215,248,.14);font-size:22rpx;font-weight:600}.notice-text{overflow:hidden;color:#fff;font-size:26rpx;font-weight:500;text-overflow:ellipsis;white-space:nowrap}.empty-notice{color:rgba(255,255,255,.75)}.more{font-size:45rpx}
 .message-list{padding-top:22rpx}
 .empty-list{padding:100rpx 0;color:#94a0ae;text-align:center;font-size:24rpx}
 .load-tip{padding:28rpx;color:#98a4b2;text-align:center;font-size:23rpx}

Diff do ficheiro suprimidas por serem muito extensas
+ 158 - 19
src/pages/message/warning-detail.vue


Diff do ficheiro suprimidas por serem muito extensas
+ 19 - 2
src/pages/message/workorder-detail.vue


+ 75 - 3
src/pages/profile/warnings.vue

@@ -1,5 +1,77 @@
 <script setup lang="ts">
-import BusinessListPage from '@/components/BusinessListPage.vue'
-import { warnings } from '@/data/mock'
+import { computed, ref, watch } from 'vue'
+import { onShow, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
+import AppHeader from '@/components/AppHeader.vue'
+import SearchBar from '@/components/SearchBar.vue'
+import SegmentTabs from '@/components/SegmentTabs.vue'
+import BusinessCard from '@/components/BusinessCard.vue'
+import type { ListItem } from '@/data/mock'
+import { getMobileWarnings, getWarningRevision, warningStatusText, warningStatusTone, type EarlyWarning } from '@/services/warnings'
+import { navigateTo } from '@/utils/navigation'
+
+const tabs = [
+  { label: '全部', status: '' }, { label: '草稿', status: 'DRAFT' }, { label: '待接收', status: 'PENDING' },
+  { label: '已接收', status: 'RECEIVED' }, { label: '处置中', status: 'PROCESSING' },
+  { label: '已处置', status: 'HANDLED' }, { label: '已解除', status: 'CLOSED' },
+]
+const active = ref(0)
+const keyword = ref('')
+const records = ref<EarlyWarning[]>([])
+const total = ref(0)
+const pageNum = ref(1)
+const loading = ref(false)
+const loaded = ref(false)
+const hasMore = computed(() => records.value.length < total.value)
+
+function card(item: EarlyWarning): ListItem {
+  return { id: item.warningNo, title: item.warningContent || item.warningTypeName || '预警', status: item.statusLabel || warningStatusText(item.status), tone: warningStatusTone(item.status), actionLabel: '详情', fields: [
+    { label: '预警内容', value: item.warningContent || '—' },
+    { label: '预警类型', value: item.warningTypeName || item.warningType || '—' },
+    { label: '关联设备', value: item.deviceCode ? `${item.deviceCode}${item.deviceName ? `(${item.deviceName})` : ''}` : '—' },
+  ] }
+}
+
+async function load(reset = false) {
+  if (loading.value || (!reset && !hasMore.value)) return
+  if (reset) { pageNum.value = 1; records.value = []; total.value = 0 }
+  loading.value = true
+  try {
+    const page = await getMobileWarnings({ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value.trim() || undefined, status: tabs[active.value].status || undefined })
+    records.value = reset ? page.records : records.value.concat(page.records)
+    total.value = Number(page.total) || 0
+    if (records.value.length < total.value) pageNum.value += 1
+  } catch (error) {
+    uni.showToast({ title: error instanceof Error ? error.message : '预警获取失败', icon: 'none' })
+  } finally { loading.value = false; loaded.value = true }
+}
+
+watch(active, () => load(true))
+let seenWarningRevision = -1
+onShow(() => {
+  const revision = getWarningRevision()
+  if (seenWarningRevision === revision) return
+  seenWarningRevision = revision
+  load(true)
+})
+onPullDownRefresh(async () => { await load(true); uni.stopPullDownRefresh() })
+onReachBottom(() => load())
 </script>
-<template><BusinessListPage title="预警管理" :items="warnings" :tabs="['全部','已接收','处理中','已处置','已解除']" detail-path="/pages/message/warning-detail" id-label="预警编号" placeholder="请输入预警编号或描述" /></template>
+
+<template>
+  <view class="page">
+    <AppHeader title="预警管理" />
+    <scroll-view class="tabs-scroll" scroll-x><SegmentTabs v-model="active" :items="tabs.map(item => item.label)" /></scroll-view>
+    <SearchBar v-model="keyword" attached placeholder="请输入预警编号、设备编码或内容" @search="load(true)" />
+    <view class="filter-bar">共 {{ total }} 条</view>
+    <view class="page-content list-content">
+      <BusinessCard v-for="item in records" :key="item.warningId" :item="card(item)" id-label="预警编号" detail-label="详情" @open="navigateTo(`/pages/message/warning-detail?id=${item.warningId}`)" />
+      <view v-if="loaded && !records.length" class="empty">暂无预警</view>
+      <view v-else-if="loading" class="load-tip">正在加载...</view>
+      <view v-else-if="records.length && !hasMore" class="load-tip">已加载全部</view>
+    </view>
+  </view>
+</template>
+
+<style scoped lang="scss">
+.tabs-scroll{width:100%;white-space:nowrap;background:#fff}.filter-bar{padding:0 26rpx 16rpx;color:#8a97a7;background:#fff;font-size:23rpx}.list-content{padding-top:22rpx}.empty{padding:120rpx 0;color:#9aa6b4;text-align:center}.load-tip{padding:28rpx;color:#98a4b2;text-align:center;font-size:23rpx}
+</style>

+ 4 - 4
src/services/alarms.ts

@@ -1,4 +1,4 @@
-import { ApiError, getApiUrl, requestApi, type ApiResponse } from './api'
+import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
 import { getToken } from '@/utils/auth'
 
 export interface Alarm {
@@ -51,10 +51,10 @@ export function uploadAlarmAttachment(id: string, path: string): Promise<string>
       header: { Authorization: `Bearer ${getToken()}` }, timeout: 60000,
       success(response) {
         try {
-          const result = JSON.parse(response.data) as DataResponse<{ token: string }>
-          if (response.statusCode >= 200 && response.statusCode < 300 && (result.code ?? 200) === 200 && result.data?.token) resolve(result.data.token)
+          const result = parseApiResponse<DataResponse<{ token: string }>>(response.statusCode, response.data)
+          if (result.data?.token) resolve(result.data.token)
           else reject(new ApiError(result.msg || '图片上传失败', result.code))
-        } catch { reject(new ApiError('图片上传响应异常')) }
+        } catch (error) { reject(error instanceof ApiError ? error : new ApiError('图片上传响应异常')) }
       },
       fail() { reject(new ApiError('图片上传失败,请重试')) },
     })

+ 36 - 13
src/services/api.ts

@@ -1,4 +1,5 @@
 import { getToken, removeToken } from '@/utils/auth'
+import { useUserStore } from '@/stores/user'
 
 export interface ApiResponse {
   code?: number
@@ -39,6 +40,37 @@ function getErrorMessage(error: UniApp.GeneralCallbackResult) {
   return '后端接口连接异常'
 }
 
+let redirectingToLogin = false
+
+export function handleUnauthorized(statusCode: number, code?: number) {
+  if (statusCode !== 401 && Number(code) !== 401) return false
+
+  removeToken()
+  useUserStore().clearProfile()
+  const pages = getCurrentPages()
+  if (!redirectingToLogin && pages[pages.length - 1]?.route !== 'pages/login/index') {
+    redirectingToLogin = true
+    uni.reLaunch({
+      url: '/pages/login/index',
+      complete() { redirectingToLogin = false },
+    })
+  }
+  return true
+}
+
+export function parseApiResponse<T extends ApiResponse>(statusCode: number, data: unknown): T {
+  if (handleUnauthorized(statusCode)) throw new ApiError('登录已失效,请重新登录', 401)
+  if (statusCode < 200 || statusCode >= 300) {
+    throw new ApiError(`系统接口 ${statusCode} 异常`, statusCode)
+  }
+
+  const result = (typeof data === 'string' ? JSON.parse(data) : data) as T
+  const code = Number(result.code ?? 200)
+  if (handleUnauthorized(statusCode, code)) throw new ApiError('登录已失效,请重新登录', 401)
+  if (code !== 200) throw new ApiError(result.msg || result.message || '系统接口请求失败', code)
+  return result
+}
+
 export function requestApi<T extends ApiResponse>(url: string, config: RequestConfig = {}): Promise<T> {
   const token = getToken()
   const header: Record<string, string> = {
@@ -58,20 +90,11 @@ export function requestApi<T extends ApiResponse>(url: string, config: RequestCo
       header,
       timeout: config.timeout || 10000,
       success(response) {
-        if (response.statusCode < 200 || response.statusCode >= 300) {
-          reject(new ApiError(`系统接口 ${response.statusCode} 异常`, response.statusCode))
-          return
+        try {
+          resolve(parseApiResponse<T>(response.statusCode, response.data))
+        } catch (error) {
+          reject(error instanceof ApiError ? error : new ApiError('系统接口响应格式异常'))
         }
-
-        const result = response.data as T
-        const code = result.code ?? 200
-        if (code === 200) {
-          resolve(result)
-          return
-        }
-
-        if (code === 401) removeToken()
-        reject(new ApiError(result.msg || result.message || '系统接口请求失败', code))
       },
       fail(error) {
         reject(new ApiError(getErrorMessage(error)))

+ 5 - 5
src/services/inspect-tasks.ts

@@ -1,4 +1,4 @@
-import { ApiError, getApiUrl, requestApi, type ApiResponse } from './api'
+import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
 import { getToken } from '@/utils/auth'
 
 export interface InspectTask {
@@ -66,14 +66,14 @@ export function uploadInspectTaskPhoto(filePath: string): Promise<string> {
       timeout: 30000,
       success(response) {
         try {
-          const result = JSON.parse(response.data) as UploadResponse
-          if (response.statusCode >= 200 && response.statusCode < 300 && (result.code ?? 200) === 200 && result.relativePath) {
+          const result = parseApiResponse<UploadResponse>(response.statusCode, response.data)
+          if (result.relativePath) {
             resolve(result.relativePath)
           } else {
             reject(new ApiError(result.msg || result.message || '现场照片上传失败', result.code))
           }
-        } catch {
-          reject(new ApiError('现场照片上传响应格式异常'))
+        } catch (error) {
+          reject(error instanceof ApiError ? error : new ApiError('现场照片上传响应格式异常'))
         }
       },
       fail(error) {

+ 4 - 12
src/services/profile.ts

@@ -1,4 +1,4 @@
-import { ApiError, getApiUrl, requestApi, type ApiResponse } from './api'
+import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
 import type { UserProfile } from './auth'
 import { getToken } from '@/utils/auth'
 
@@ -36,18 +36,10 @@ export function uploadAvatar(filePath: string): Promise<AvatarResponse> {
       header: { Authorization: `Bearer ${getToken()}` },
       timeout: 20000,
       success(response) {
-        if (response.statusCode < 200 || response.statusCode >= 300) {
-          reject(new ApiError(`系统接口 ${response.statusCode} 异常`, response.statusCode))
-          return
-        }
-
         try {
-          const result = JSON.parse(response.data) as AvatarResponse
-          const code = result.code ?? 200
-          if (code === 200) resolve(result)
-          else reject(new ApiError(result.msg || result.message || '头像上传失败', code))
-        } catch {
-          reject(new ApiError('头像上传响应格式异常'))
+          resolve(parseApiResponse<AvatarResponse>(response.statusCode, response.data))
+        } catch (error) {
+          reject(error instanceof ApiError ? error : new ApiError('头像上传响应格式异常'))
         }
       },
       fail(error) {

+ 180 - 0
src/services/warnings.ts

@@ -0,0 +1,180 @@
+import { ApiError, getApiUrl, handleUnauthorized, parseApiResponse, requestApi, type ApiResponse } from './api'
+import { getToken } from '@/utils/auth'
+
+export type WarningAction = 'PUBLISH' | 'RECEIVE' | 'START' | 'HANDLE' | 'CLEAR' | 'RETURN'
+
+export interface EarlyWarning {
+  warningId: string
+  warningNo: string
+  warningType?: string
+  warningTypeName?: string
+  warningLevel?: string
+  warningLevelName?: string
+  warningSpecial?: string
+  warningContent?: string
+  deviceCode?: string
+  deviceName?: string
+  location?: string
+  ownershipUnit?: string
+  publisher?: string
+  publisherName?: string
+  handler?: string
+  handlerName?: string
+  reviewer?: string
+  reviewerName?: string
+  status: string
+  statusLabel?: string
+  publishTime?: string
+  createTime?: string
+  availableActions?: WarningAction[]
+}
+
+export interface WarningDisposal {
+  disposal_id?: string
+  disposalId?: string
+  disposal_type?: string
+  disposalType?: string
+  disposalTypeName?: string
+  disposal_user?: string
+  disposalUser?: string
+  disposal_user_name?: string
+  disposalUserName?: string
+  disposal_content?: string
+  disposalContent?: string
+  create_time?: string
+  createTime?: string
+}
+
+export interface WarningAttachment {
+  attachmentId: string
+  attachmentName?: string
+  attachmentType?: string
+  attachmentSize?: number
+  attachmentStage?: string
+  disposalId?: string
+  createTime?: string
+  localUrl?: string
+}
+
+export interface WarningDetail {
+  basicInfo: EarlyWarning
+  disposalHistory: WarningDisposal[]
+  attachmentList: WarningAttachment[]
+}
+
+export interface WarningParticipant {
+  userId: number
+  userName: string
+  nickName: string
+  deptId?: number
+  deptName?: string
+}
+
+export interface WarningQuery {
+  pageNum?: number
+  pageSize?: number
+  keyword?: string
+  status?: string
+  warningType?: string
+  warningLevel?: string
+}
+
+export interface PageResult<T> { records: T[]; total: number; current: number; size: number; pages?: number }
+interface DataResponse<T> extends ApiResponse { data: T }
+
+const BASE = '/api/mobile/warning'
+const value = (source: Record<string, unknown>, camel: string, snake: string) => source[camel] ?? source[snake]
+
+function normalizeWarning(source: Record<string, unknown>): EarlyWarning {
+  return {
+    ...source,
+    warningId: String(value(source, 'warningId', 'warning_id') || ''),
+    warningNo: String(value(source, 'warningNo', 'warning_no') || ''),
+    warningType: value(source, 'warningType', 'warning_type') as string | undefined,
+    warningTypeName: value(source, 'warningTypeName', 'warning_type_name') as string | undefined,
+    warningLevel: value(source, 'warningLevel', 'warning_level') as string | undefined,
+    warningLevelName: value(source, 'warningLevelName', 'warning_level_name') as string | undefined,
+    warningSpecial: value(source, 'warningSpecial', 'warning_special') as string | undefined,
+    warningContent: value(source, 'warningContent', 'warning_content') as string | undefined,
+    deviceCode: value(source, 'deviceCode', 'device_code') as string | undefined,
+    deviceName: value(source, 'deviceName', 'device_name') as string | undefined,
+    ownershipUnit: value(source, 'ownershipUnit', 'ownership_unit') as string | undefined,
+    publisherName: value(source, 'publisherName', 'publisher_name') as string | undefined,
+    handlerName: value(source, 'handlerName', 'handler_name') as string | undefined,
+    reviewerName: value(source, 'reviewerName', 'reviewer_name') as string | undefined,
+    publishTime: value(source, 'publishTime', 'publish_time') as string | undefined,
+    createTime: value(source, 'createTime', 'create_time') as string | undefined,
+    status: String(source.status || ''),
+    statusLabel: value(source, 'statusLabel', 'status_name') as string | undefined,
+    availableActions: (source.availableActions || []) as WarningAction[],
+  }
+}
+
+export function getMobileWarnings(query: WarningQuery = {}) {
+  return requestApi<DataResponse<PageResult<Record<string, unknown>>>>(BASE, { data: query }).then(result => ({ ...result.data, records: result.data.records.map(normalizeWarning) }))
+}
+
+export function getMobilePendingWarnings(query: WarningQuery = {}) {
+  return requestApi<DataResponse<PageResult<Record<string, unknown>>>>(`${BASE}/pending`, { data: query }).then(result => ({ ...result.data, records: result.data.records.map(normalizeWarning) }))
+}
+
+export function getMobileWarningDetail(id: string) {
+  return requestApi<DataResponse<Record<string, unknown>>>(`${BASE}/${encodeURIComponent(id)}`).then(result => {
+    const data = result.data
+    return {
+      basicInfo: normalizeWarning(data.basicInfo as Record<string, unknown>),
+      disposalHistory: (data.disposalHistory || []) as WarningDisposal[],
+      attachmentList: (data.attachmentList || []) as WarningAttachment[],
+    } as WarningDetail
+  })
+}
+
+export const getWarningParticipants = (warningId: string, role: 'handler' | 'reviewer') => requestApi<DataResponse<WarningParticipant[]>>(`${BASE}/participants`, { data: { warningId, role } }).then(result => result.data)
+let warningRevision = 0
+export const getWarningRevision = () => warningRevision
+const post = (id: string, action: string, data: Record<string, unknown>) => requestApi<ApiResponse>(`${BASE}/${encodeURIComponent(id)}/${action}`, { method: 'POST', data }).then(result => {
+  warningRevision++
+  return result
+})
+export const publishWarning = (id: string, handler: string, reviewer: string) => post(id, 'publish', { handler, reviewer })
+export const receiveWarning = (id: string, remark: string) => post(id, 'receive', { remark })
+export const startWarning = (id: string, remark: string) => post(id, 'start', { remark })
+export const processWarning = (id: string, content: string, attachmentTokens: string[]) => post(id, 'process', { content, attachmentTokens, reasonIds: [] })
+export const verifyWarning = (id: string, result: 'CLEAR' | 'RETURN', opinion: string) => post(id, 'verify', { result, opinion })
+
+export function uploadWarningProcessImage(id: string, filePath: string): Promise<string> {
+  return new Promise((resolve, reject) => uni.uploadFile({
+    url: getApiUrl(`${BASE}/${encodeURIComponent(id)}/process/attachments`), filePath, name: 'file',
+    header: { Authorization: `Bearer ${getToken()}` }, timeout: 60000,
+    success(response) {
+      try {
+        const result = parseApiResponse<DataResponse<{ token: string }>>(response.statusCode, response.data)
+        if (result.data?.token) resolve(result.data.token)
+        else reject(new ApiError(result.msg || '图片上传失败', result.code))
+      } catch (error) { reject(error instanceof ApiError ? error : new ApiError('图片上传响应异常')) }
+    },
+    fail() { reject(new ApiError('图片上传失败,请重试')) },
+  }))
+}
+
+export function downloadWarningAttachment(attachmentId: string): Promise<string> {
+  return new Promise((resolve, reject) => uni.downloadFile({
+    url: getApiUrl(`${BASE}/attachments/${encodeURIComponent(attachmentId)}`),
+    header: { Authorization: `Bearer ${getToken()}` }, timeout: 60000,
+    success(response) {
+      if (handleUnauthorized(response.statusCode)) reject(new ApiError('登录已失效,请重新登录', 401))
+      else if (response.statusCode >= 200 && response.statusCode < 300) resolve(response.tempFilePath)
+      else reject(new ApiError('附件读取失败', response.statusCode))
+    },
+    fail() { reject(new ApiError('附件读取失败')) },
+  }))
+}
+
+export const warningStatusText = (status?: string) => ({ DRAFT: '草稿', PENDING: '待接收', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }[status || ''] || '未知')
+export const warningStatusTone = (status?: string) => status === 'DRAFT' ? 'gray' : status === 'PENDING' ? 'orange' : status === 'PROCESSING' ? 'purple' : ['HANDLED', 'CLOSED'].includes(status || '') ? 'green' : ''
+export function warningTime(time?: string) {
+  const value = time?.trim().replace('T', ' ')
+  if (!value) return '—'
+  return value.replace(/(\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/, '$1')
+}
+export const isImageAttachment = (item: WarningAttachment) => /image\//i.test(item.attachmentType || '') || /\.(jpe?g|png|gif|webp)$/i.test(item.attachmentName || '')

+ 8 - 7
src/services/workorders.ts

@@ -1,4 +1,4 @@
-import { ApiError, getApiUrl, requestApi, type ApiResponse } from './api'
+import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
 import { getToken } from '@/utils/auth'
 
 export interface WorkOrder {
@@ -24,6 +24,7 @@ export interface WorkOrder {
   receiveUser?: number
   receiveUserName?: string
   verifyUser?: number
+  verifyUserName?: string
   createBy?: number
   createTime?: string
 }
@@ -89,8 +90,8 @@ export function getMobileWorkOrderDetail(orderId: number) {
   return requestApi<DataResponse<WorkOrderDetail>>(`${MOBILE_BASE}/${orderId}`).then(result => result.data)
 }
 
-export function getAssignableUsers(type: 'handler' | 'reviewer') {
-  return requestApi<DataResponse<AssignableUser[]>>(`${MOBILE_BASE}/assignable/users`, { data: { type } }).then(result => result.data)
+export function getAssignableUsers(orderId: number, type: 'handler' | 'reviewer') {
+  return requestApi<DataResponse<AssignableUser[]>>(`${MOBILE_BASE}/assignable/users`, { data: { orderId, type } }).then(result => result.data)
 }
 
 function operate(orderId: number, action: string, data: Record<string, unknown>) {
@@ -116,14 +117,14 @@ export function uploadWorkOrderFile(filePath: string): Promise<string> {
       timeout: 30000,
       success(response) {
         try {
-          const result = JSON.parse(response.data) as UploadResponse
-          if (response.statusCode >= 200 && response.statusCode < 300 && (result.code ?? 200) === 200 && result.relativePath) {
+          const result = parseApiResponse<UploadResponse>(response.statusCode, response.data)
+          if (result.relativePath) {
             resolve(result.relativePath)
           } else {
             reject(new ApiError(result.msg || result.message || '附件上传失败', result.code))
           }
-        } catch {
-          reject(new ApiError('附件上传响应格式异常'))
+        } catch (error) {
+          reject(error instanceof ApiError ? error : new ApiError('附件上传响应格式异常'))
         }
       },
       fail(error) { reject(new ApiError(error.errMsg?.includes('timeout') ? '附件上传超时' : '附件上传失败')) },

+ 46 - 0
tests/warningActionDrawer.test.mjs

@@ -0,0 +1,46 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import ts from 'typescript'
+import { computed, nextTick, reactive, ref, watch } from 'vue'
+
+const source = readFileSync(new URL('../src/components/WarningActionDrawer.vue', import.meta.url), 'utf8')
+const script = ts.transpileModule(source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
+  .replace(/^import .*\r?\n/gm, ''), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
+function setup(reviewers) {
+  const props = reactive({ modelValue: false, action: 'PUBLISH', handlers: [{ userName: 'handler' }], reviewers })
+  const events = [], messages = []
+  const state = new Function('computed', 'ref', 'watch', 'useUserStore', 'defineProps', 'withDefaults', 'defineEmits', 'uni',
+    `${script}; return { handlerIndex, reviewerIndex, selectIndex, submit }`)(computed, ref, watch,
+    () => ({ profile: { userName: 'current' } }), () => props, p => p,
+    () => (...args) => events.push(args), { showToast: message => messages.push(message) })
+  return { ...state, props, events, messages }
+}
+
+test('移动端仅对服务端返回的合格核验候选默认当前账户', async () => {
+  for (const eligible of [true, false]) {
+    const s = setup(eligible ? [{ userName: 'current' }, { userName: 'other' }] : [{ userName: 'other' }])
+    s.props.modelValue = true
+    await nextTick()
+    assert.equal(s.handlerIndex.value, -1)
+    assert.equal(s.reviewerIndex.value, eligible ? 0 : -1)
+    s.submit()
+    assert.equal(s.events.length, 0)
+    assert.equal(s.messages.length, 1)
+  }
+})
+
+test('首位候选可以明确选中,核验人支持改选其他人员', async () => {
+  const s = setup([{ userName: 'current' }, { userName: 'other' }])
+  s.props.modelValue = true
+  await nextTick()
+  s.selectIndex({ detail: { value: '1' } }, 'handler')
+  s.selectIndex({ detail: { value: '2' } }, 'reviewer')
+  s.submit()
+  assert.equal(s.events[0][0], 'submit')
+  assert.equal(s.events[0][1].handler, 'handler')
+  assert.equal(s.events[0][1].reviewer, 'other')
+  s.selectIndex({ detail: { value: '0' } }, 'reviewer')
+  s.submit()
+  assert.equal(s.events.length, 1)
+})

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff