Explorar el Código

应急-事件管理,预案管理

zengy hace 10 horas
padre
commit
d6bd9f3029

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
src/components/ActionFormDrawer.vue


+ 2 - 2
src/components/BusinessCard.vue

@@ -2,7 +2,7 @@
 import { computed } from 'vue'
 import type { ListItem } from '@/data/mock'
 
-const props = withDefaults(defineProps<{ item: ListItem; idLabel?: string; detailLabel?: string }>(), { detailLabel: '查看详情' })
+const props = withDefaults(defineProps<{ item: ListItem; idLabel?: string; detailLabel?: string; showId?: boolean }>(), { detailLabel: '查看详情', showId: true })
 defineEmits<{ open: [item: ListItem] }>()
 const splitNumber = computed(() => props.idLabel === '工单编号' || props.idLabel === '预警编号')
 </script>
@@ -15,7 +15,7 @@ const splitNumber = computed(() => props.idLabel === '工单编号' || props.idL
     </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-if="showId" class="number"><text>{{ idLabel || '编号' }}</text><text>{{ item.displayId || 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" :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>

+ 2 - 2
src/data/mock.ts

@@ -1,7 +1,7 @@
-export type Tone = 'red' | 'orange' | 'green' | 'gray' | 'purple' | ''
+export type Tone = 'red' | 'orange' | 'teal' | 'green' | 'gray' | 'purple' | ''
 
 export interface Field { label: string; value: string }
-export interface ListItem { id: string; title: string; status: string; tone?: Tone; secondaryStatus?: string; secondaryTone?: Tone; fields: Field[]; actionLabel?: string }
+export interface ListItem { id: string; displayId?: string; title: string; status: string; tone?: Tone; secondaryStatus?: string; secondaryTone?: Tone; fields: Field[]; actionLabel?: string }
 
 export const workOrders: ListItem[] = [
   { id: 'GD202609291256557016', title: '井盖异常开启告警', status: '待派单', tone: 'orange', fields: [

+ 6 - 5
src/pages.json

@@ -10,7 +10,7 @@
     { "path": "pages/login/index", "style": { "navigationStyle": "custom" } },
     { "path": "pages/message/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
     { "path": "pages/office/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
-    { "path": "pages/emergency/index", "style": { "navigationStyle": "custom" } },
+    { "path": "pages/emergency/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
     { "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 } },
@@ -25,11 +25,12 @@
     { "path": "pages/office/patrol-detail", "style": { "navigationStyle": "custom" } },
     { "path": "pages/office/daily-patrol", "style": { "navigationStyle": "custom" } },
     { "path": "pages/office/event-report", "style": { "navigationStyle": "custom" } },
-    { "path": "pages/emergency/events", "style": { "navigationStyle": "custom" } },
-    { "path": "pages/emergency/plans", "style": { "navigationStyle": "custom" } },
-    { "path": "pages/emergency/plan-detail", "style": { "navigationStyle": "custom" } },
+    { "path": "pages/emergency/events", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
+    { "path": "pages/emergency/event-detail", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
+    { "path": "pages/emergency/plans", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
+    { "path": "pages/emergency/plan-detail", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
     { "path": "pages/emergency/dispatch", "style": { "navigationStyle": "custom" } },
-    { "path": "pages/emergency/audit", "style": { "navigationStyle": "custom" } },
+    { "path": "pages/emergency/audit", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
     { "path": "pages/profile/workorders", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
       { "path": "pages/profile/warnings", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true, "onReachBottomDistance": 80 } },
     { "path": "pages/profile/basic", "style": { "navigationStyle": "custom" } },

+ 50 - 5
src/pages/emergency/audit.vue

@@ -1,12 +1,57 @@
 <script setup lang="ts">
 import { ref } from 'vue'
+import { onShow, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
 import AppHeader from '@/components/AppHeader.vue'
 import SegmentTabs from '@/components/SegmentTabs.vue'
-import { plans } from '@/data/mock'
-import { showStaticToast } from '@/utils/navigation'
-const active=ref(0)
+import { navigateTo } from '@/utils/navigation'
+import { useUserStore } from '@/stores/user'
+import { getEmergencyAudit, planStatusText, planStatusTone, type EmergencyPlan } from '@/services/emergency'
+
+const active = ref(0)
+const plans = ref<EmergencyPlan[]>([])
+const loading = ref(false)
+const page = ref(1), total = ref(0), finished = ref(false), pageSize = 10
+const userStore = useUserStore()
+
+async function load(reset = true) {
+  if (loading.value) return
+  if (reset) { page.value = 1; finished.value = false }
+  if (finished.value) return
+  loading.value = true
+  try {
+    const result = await getEmergencyAudit({ status: active.value + 1, pageNum: page.value, pageSize })
+    const rows = result.records || []
+    plans.value = reset ? rows : [...plans.value, ...rows]
+    total.value = result.total || 0
+    page.value++
+    finished.value = plans.value.length >= total.value || rows.length < pageSize
+  } catch (error) {
+    uni.showToast({ title: error instanceof Error ? error.message : '审核列表加载失败', icon: 'none' })
+  } finally { loading.value = false }
+}
+
+onShow(async () => { await userStore.fetchProfile().catch(() => undefined); await load() })
+onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh() })
+onReachBottom(() => { if (!loading.value) load(false) })
+
+function viewDetail(item: EmergencyPlan) { navigateTo(`/pages/emergency/plan-detail?id=${item.id}`) }
 </script>
 
-<template><view class="page"><AppHeader title="信息审核" /><SegmentTabs v-model="active" :items="['待审核','已通过','已驳回']" /><view class="page-content"><view v-for="(item,index) in plans" :key="item.id" class="audit card"><view class="head"><view><text class="name">{{ item.title }}</text><text class="no">{{ item.id }}</text></view><text class="status-pill" :class="index===0?'orange':index===1?'green':'red'">{{ index===0?'待审核':index===1?'已通过':'已驳回' }}</text></view><view class="row"><text>预案类型</text><text>{{ item.fields[0].value }}</text></view><view class="row"><text>编制单位</text><text>{{ item.fields[1].value }}</text></view><view class="row"><text>提交时间</text><text>2026-09-28 10:25</text></view><view v-if="index===0" class="actions"><button @tap="showStaticToast('审核意见静态演示')">驳回</button><button @tap="showStaticToast('审核通过静态演示')">通过</button></view></view></view></view></template>
+<template>
+  <view class="page">
+    <AppHeader title="处置方案" />
+    <SegmentTabs v-model="active" :items="['待审核','已通过','已驳回']" @update:model-value="() => load()" />
+    <view class="page-content">
+      <view v-for="item in plans" :key="item.id" class="audit card" @tap="viewDetail(item)">
+        <view class="head"><view><text class="name">{{ item.planName || '未命名预案' }}{{ item.version ? ` · ${item.version}` : '' }}</text></view><text class="status-pill" :class="planStatusTone(item.status ?? item.statusName)">{{ planStatusText(item.status ?? item.statusName) }}</text></view>
+        <view class="secondary-meta"><view><text>预案编号</text><text>{{ item.planNo || '—' }}</text></view><view><text>预案类型</text><text>{{ item.planTypeName || '—' }}</text></view><view><text>预案等级</text><text>{{ item.planLevelName || '—' }}</text></view><view><text>编制单位</text><text>{{ item.compileUnit || '—' }}</text></view><view><text>提交时间</text><text>{{ item.createTime || '—' }}</text></view></view>
+        <view class="actions"><text class="view-btn" @tap.stop="viewDetail(item)">查看详情</text><text class="view-arrow">›</text></view>
+      </view>
+      <view v-if="loading" class="empty">正在加载...</view><view v-else-if="!plans.length" class="empty">暂无数据</view><view v-else-if="finished" class="end">没有更多了</view>
+    </view>
+  </view>
+</template>
 
-<style scoped lang="scss">.audit{padding:25rpx;margin-bottom:20rpx}.head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:19rpx;margin-bottom:7rpx;border-bottom:1rpx solid #edf1f5}.head view{display:flex;flex:1;flex-direction:column;min-width:0}.name{overflow:hidden;color:#283c54;font-size:29rpx;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.no{margin-top:9rpx;color:#929eac;font-size:22rpx}.row{display:flex;justify-content:space-between;padding-top:15rpx;color:#495b70;font-size:24rpx}.row text:first-child{color:#8d99a8}.actions{display:grid;grid-template-columns:1fr 1fr;gap:18rpx;padding-top:20rpx;margin-top:19rpx;border-top:1rpx solid #edf1f5}.actions button{height:62rpx;margin:0;color:#e05258;border:1rpx solid #f1c8ca;border-radius:999rpx;background:#fff;font-size:24rpx;line-height:60rpx}.actions button:last-child{color:#fff;border-color:#08264c;background:#08264c}</style>
+<style scoped lang="scss">
+.audit{padding:25rpx;margin-bottom:20rpx}.head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18rpx;margin-bottom:2rpx;border-bottom:1rpx solid #edf1f5}.head view{display:flex;flex:1;min-width:0}.name{overflow:hidden;color:#283c54;font-size:29rpx;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.secondary-meta{display:flex;flex-direction:column;gap:17rpx;padding-top:17rpx}.secondary-meta>view{display:flex;justify-content:space-between;gap:14rpx;line-height:1.55}.secondary-meta text:first-child{flex:none;color:#8b98a8;font-size:25rpx}.secondary-meta text:last-child{min-width:0;color:#43546a;font-size:25rpx;text-align:right;overflow-wrap:anywhere}.actions{display:flex;align-items:center;justify-content:flex-end;gap:12rpx;padding-top:17rpx;margin-top:17rpx;border-top:1rpx solid #edf1f5}.actions button{min-width:130rpx;height:60rpx;margin:0;padding:0 18rpx;color:#43566d;border:1rpx solid #d7e0e9;border-radius:999rpx;background:#fff;font-size:23rpx;line-height:58rpx}.actions .view-btn{color:#08264c;font-size:25rpx;line-height:1}.actions .view-arrow{transform:translateY(-2rpx);color:#08264c;font-size:40rpx;font-weight:300;line-height:1}.empty,.end{padding:55rpx 0;color:#9aa6b4;text-align:center}
+</style>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 176 - 0
src/pages/emergency/event-detail.vue


+ 19 - 3
src/pages/emergency/events.vue

@@ -1,5 +1,21 @@
 <script setup lang="ts">
-import BusinessListPage from '@/components/BusinessListPage.vue'
-import { events } from '@/data/mock'
+import { computed, ref } from 'vue'
+import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
+import AppHeader from '@/components/AppHeader.vue'
+import SegmentTabs from '@/components/SegmentTabs.vue'
+import SearchBar from '@/components/SearchBar.vue'
+import BusinessCard from '@/components/BusinessCard.vue'
+import { navigateTo } from '@/utils/navigation'
+import { eventStatusText, eventStatusTone, getEmergencyEvents, type EmergencyEvent } from '@/services/emergency'
+const events = ref<EmergencyEvent[]>([]), total = ref(0), active = ref(0), keyword = ref(''), loading = ref(false), page = ref(1), pageSize = 10, finished = ref(false)
+const tabs = ['全部', '待核实', '已核实', '处置中', '待验收', '已解除']
+const statusMap: Record<number, number> = { 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5 }
+const cards = computed(() => events.value.filter(item => !keyword.value || `${item.eventNo}${item.description}${item.location}`.includes(keyword.value)).map(item => ({ id: String(item.id), title: item.description || item.eventNo || '应急事件', status: eventStatusText(item.status), tone: eventStatusTone(item.status) as any, fields: [{ label: '事件编号', value: item.eventNo || '—' }, { label: '发生地点', value: item.location || '—' }, { label: '事件等级', value: item.eventLevelName || '—' }] })))
+async function load(reset = true) { if (loading.value) return; if (reset) { page.value = 1; finished.value = false }; if (finished.value) return; loading.value = true; try { const result = await getEmergencyEvents({ ...(active.value ? { status: statusMap[active.value] } : {}), ...(keyword.value ? { keyword: keyword.value } : {}), pageNum: page.value, pageSize }); const records = result.records || []; events.value = reset ? records : [...events.value, ...records]; total.value = result.total || 0; page.value++; finished.value = events.value.length >= total.value || records.length < pageSize } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '事件列表加载失败', icon: 'none' }) } finally { loading.value = false } }
+onLoad(() => load())
+onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh() })
+onReachBottom(() => { if (!loading.value) load(false) })
+function open(item: { id: string }) { navigateTo(`/pages/emergency/event-detail?id=${item.id}`) }
 </script>
-<template><BusinessListPage title="事件信息" :items="events" :tabs="['全部','核实中','已核实','处置中','已解除']" detail-path="/pages/message/alarm-detail" id-label="事件编号" placeholder="请输入事件编号或描述" /></template>
+<template><view class="page"><AppHeader title="事件信息" /><SegmentTabs v-model="active" :items="tabs" @update:model-value="() => load()" /><SearchBar v-model="keyword" placeholder="请输入事件编号或描述" @search="() => load()" /><view class="filter-bar">共 {{ total }} 条</view><view class="page-content list-content"><BusinessCard v-for="item in cards" :key="item.id" :item="item" id-label="事件编号" @open="open" /><view v-if="loading" class="empty">正在加载...</view><view v-else-if="!cards.length" class="empty">暂无事件</view><view v-else-if="finished" class="end">没有更多了</view></view></view></template>
+<style scoped lang="scss">.filter-bar{padding:0 26rpx 16rpx;color:#8a97a7;background:#fff;font-size:23rpx}.list-content{padding-top:22rpx}.empty,.end{padding:45rpx 0;color:#9aa6b4;text-align:center}</style>

+ 17 - 2
src/pages/emergency/index.vue

@@ -1,5 +1,8 @@
 <script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
 import { navigateTo } from '@/utils/navigation'
+import { getEmergencyHome, type EmergencyEvent } from '@/services/emergency'
 
 const menus = [
   { title: '事件信息', sub: '查看突发事件动态', icon: '⚑', path: '/pages/emergency/events' },
@@ -7,17 +10,28 @@ const menus = [
   { title: '调度指挥', sub: '下发应急调度指令', icon: '⌁', path: '/pages/emergency/dispatch' },
   { title: '信息审核', sub: '审核预案与信息', icon: '✓', path: '/pages/emergency/audit' },
 ]
+const loading = ref(true)
+const home = ref({ pendingCount: 0, disposingCount: 0, releasedCount: 0, dynamics: [] as EmergencyEvent[] })
+async function load() {
+  try { home.value = await getEmergencyHome() } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '应急数据加载失败', icon: 'none' }) }
+  finally { loading.value = false }
+}
+onLoad(load)
+onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh() })
+function openEvent(item: EmergencyEvent) { navigateTo(`/pages/emergency/event-detail?id=${item.id}`) }
 </script>
 
 <template>
   <view class="page emergency-page">
     <view class="emergency-head"><view class="shield">◇</view><text class="head-title">应急处置</text><text class="head-sub">统一指挥 · 快速响应 · 协同处置</text></view>
     <view class="page-content emergency-content">
-      <view class="summary card"><view><text class="num red">2</text><text>待核实事件</text></view><view><text class="num orange">3</text><text>处置中事件</text></view><view><text class="num green">18</text><text>本月办结事件</text></view></view>
+      <view class="summary card"><view><text class="num red">{{ home.pendingCount }}</text><text>待核实事件</text></view><view><text class="num orange">{{ home.disposingCount }}</text><text>处置中事件</text></view><view><text class="num green">{{ home.releasedCount }}</text><text>本月办结事件</text></view></view>
       <view class="section-title">应急应用</view>
       <view class="menu-grid"><view v-for="item in menus" :key="item.title" class="menu card" @tap="navigateTo(item.path)"><view class="menu-icon">{{ item.icon }}</view><text class="menu-title">{{ item.title }}</text><text class="menu-sub">{{ item.sub }}</text></view></view>
       <view class="section-title">应急动态</view>
-      <view class="dynamic card"><view class="dynamic-icon">!</view><view class="dynamic-main"><text class="dynamic-title">供水管网爆管事件正在处置</text><text class="dynamic-sub">黄浦区·中山东路 · 10分钟前</text></view><text class="arrow">›</text></view>
+      <view v-if="loading" class="empty card">正在加载应急动态...</view>
+      <view v-else-if="!home.dynamics.length" class="empty card">暂无正在处理的事件</view>
+      <view v-for="item in home.dynamics" v-else :key="item.id" class="dynamic card" @tap="openEvent(item)"><view class="dynamic-icon">!</view><view class="dynamic-main"><text class="dynamic-title">{{ item.description || item.eventNo || '应急事件' }}</text><text class="dynamic-sub">{{ item.location || '地点未记录' }} · {{ item.statusName || '处置中' }}</text></view><text class="arrow">›</text></view>
     </view>
   </view>
 </template>
@@ -26,4 +40,5 @@ const menus = [
 .emergency-head{display:flex;flex-direction:column;align-items:center;height:330rpx;padding-top:calc(47rpx + env(safe-area-inset-top));color:#fff;background:linear-gradient(145deg,#061b34,#0b3156 55%,#155376)}.shield{display:flex;align-items:center;justify-content:center;width:90rpx;height:90rpx;border:2rpx solid rgba(255,255,255,.42);border-radius:24rpx;background:rgba(255,255,255,.11);font-size:55rpx}.head-title{margin-top:19rpx;font-size:38rpx;font-weight:650}.head-sub{margin-top:10rpx;color:rgba(255,255,255,.72);font-size:23rpx}
 .emergency-content{position:relative;margin-top:-45rpx}.summary{display:grid;grid-template-columns:repeat(3,1fr);padding:27rpx 0}.summary view{display:flex;flex-direction:column;align-items:center;color:#7b899a;font-size:22rpx;border-right:1rpx solid #eef1f5}.summary view:last-child{border:0}.num{margin-bottom:8rpx;font-size:40rpx;font-weight:700}.red{color:#e95157}.orange{color:#eb8b2c}.green{color:#15a36c}
 .menu-grid{display:grid;grid-template-columns:1fr 1fr;gap:20rpx}.menu{display:flex;flex-direction:column;align-items:center;padding:30rpx 15rpx;border:1rpx solid #e1e9ea}.menu-icon{display:flex;align-items:center;justify-content:center;width:86rpx;height:86rpx;color:#fff;border-radius:22rpx;background:#08264c;font-size:42rpx}.menu-title{margin-top:18rpx;color:#263a51;font-size:28rpx;font-weight:650}.menu-sub{margin-top:7rpx;color:#929ead;font-size:21rpx}.dynamic{display:flex;align-items:center;padding:24rpx}.dynamic-icon{display:flex;align-items:center;justify-content:center;width:70rpx;height:70rpx;margin-right:18rpx;color:#fff;border-radius:18rpx;background:#08264c;font-size:36rpx;font-weight:700}.dynamic-main{display:flex;flex:1;flex-direction:column}.dynamic-title{color:#314359;font-size:27rpx}.dynamic-sub{margin-top:9rpx;color:#95a0ae;font-size:22rpx}.arrow{color:#a9b3c0;font-size:48rpx}
+.empty{padding:35rpx;color:#95a0ae;text-align:center;font-size:24rpx}
 </style>

+ 98 - 5
src/pages/emergency/plan-detail.vue

@@ -1,10 +1,103 @@
 <script setup lang="ts">
+import { computed, ref } from 'vue'
+import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
 import AppHeader from '@/components/AppHeader.vue'
-import FieldList from '@/components/FieldList.vue'
-const fields=[{label:'预案编号',value:'PA-2026-018'},{label:'预案名称',value:'供水管网爆管专项预案'},{label:'预案等级',value:'Ⅰ级'},{label:'预案类型',value:'专项'},{label:'编制单位',value:'市水务局'},{label:'版本',value:'v3.0'}]
-const chapters=[['一、总则','为规范供水管网爆管突发事件应急处置,保障城市供水安全,制定本预案。'],['二、组织机构与职责','成立现场指挥部,设抢修、疏散、保障三个工作组。'],['三、响应分级','Ⅰ级(全市停水)/ Ⅱ级(区域停水)/ Ⅲ级(局部停水)。'],['四、处置程序','关阀止水 → 抢修 → 冲洗排气 → 恢复供水。']]
+import FormField from '@/components/FormField.vue'
+import { hasUserPermission, useUserStore } from '@/stores/user'
+import { getEmergencyPlan, planStatusCode, reviewEmergencyPlan, type EmergencyPlan } from '@/services/emergency'
+
+const plan = ref<EmergencyPlan>()
+const loading = ref(true)
+const id = ref(0)
+const drawer = ref(false)
+const note = ref('')
+const auditResult = ref<'通过' | '驳回'>('通过')
+const submitting = ref(false)
+const userStore = useUserStore()
+const canAudit = computed(() => planStatusCode(plan.value?.status ?? plan.value?.statusName) === 1 && hasUserPermission('dispatch:plan:approve'))
+
+async function load() {
+  loading.value = true
+  try {
+    if (id.value) plan.value = await getEmergencyPlan(id.value)
+  } catch (error) {
+    uni.showToast({ title: error instanceof Error ? error.message : '方案详情加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+onLoad(async options => {
+  id.value = Number(options?.id || 0)
+  await Promise.all([load(), userStore.fetchProfile().catch(() => undefined)])
+})
+onPullDownRefresh(async () => { try { await load() } finally { uni.stopPullDownRefresh() } })
+
+function openAudit() {
+  if (!canAudit.value) return
+  note.value = ''
+  auditResult.value = '通过'
+  drawer.value = true
+}
+async function submit() {
+  if (!canAudit.value || submitting.value) return
+  if (auditResult.value === '驳回' && !note.value.trim()) {
+    uni.showToast({ title: '驳回时请填写审核意见', icon: 'none' })
+    return
+  }
+  submitting.value = true
+  try {
+    await reviewEmergencyPlan(id.value, auditResult.value === '通过', note.value.trim())
+    drawer.value = false
+    await load()
+    uni.showToast({ title: auditResult.value === '通过' ? '审核通过' : '已驳回', icon: 'success' })
+  } catch (error) {
+    uni.showToast({ title: error instanceof Error ? error.message : '审核失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
 </script>
 
-<template><view class="page"><AppHeader title="预案预览" /><view class="page-content"><view class="plan-cover card"><view class="file-icon">▤</view><text class="title">供水管网爆管专项预案</text><text class="version">版本 v3.0 · 专项 · Ⅰ级 · 市水务局</text></view><view class="section-title">预案信息</view><FieldList :fields="fields" /><view class="section-title">预案正文</view><view class="article card"><view v-for="chapter in chapters" :key="chapter[0]" class="chapter"><text>{{ chapter[0] }}</text><text>{{ chapter[1] }}</text></view></view></view></view></template>
+<template>
+  <view class="page" :class="{ 'has-actions': canAudit }">
+    <AppHeader title="预案预览" />
+    <view v-if="loading" class="state-tip">正在加载...</view>
+    <view v-else-if="plan" class="page-content">
+      <view class="plan-cover card">
+        <view class="file-icon">▤</view>
+        <text class="title">{{ plan.planName || '未命名预案' }}{{ plan.version ? ` · ${plan.version}` : '' }}</text>
+        <text class="version">{{ plan.planNo || '—' }} · {{ plan.planTypeName || '—' }} · {{ plan.planLevelName || '—' }} · {{ plan.compileUnit || '—' }}</text>
+      </view>
+      <view class="section-title">预案正文</view>
+      <view class="article card">
+        <rich-text v-if="plan.content" :nodes="plan.content" />
+        <text v-else>暂无正文</text>
+      </view>
+    </view>
+    <view v-else class="state-tip">预案不存在或无权查看</view>
+    <view v-if="canAudit && !loading" class="bottom-actions"><button @tap="openAudit">审核</button></view>
+    <view v-if="drawer" class="drawer-root">
+      <view class="mask" @tap="!submitting && (drawer=false)"></view>
+      <view class="drawer">
+        <view class="drawer-head"><text>预案审核</text><text v-if="!submitting" class="close" @tap="drawer=false">×</text></view>
+        <view class="drawer-body">
+          <text class="target">{{ plan?.planName }}</text>
+          <view class="choice-field"><text class="field-label"><text class="required-mark">*</text>审核结果</text><view class="choice-row"><button :disabled="submitting" :class="{ active: auditResult === '通过' }" @tap="auditResult = '通过'">审核通过</button><button :disabled="submitting" :class="{ active: auditResult === '驳回' }" @tap="auditResult = '驳回'">审核驳回</button></view></view>
+          <FormField v-model="note" label="审核意见" type="textarea" :required="auditResult === '驳回'" placeholder="通过可不填;驳回必填" />
+        </view>
+        <view class="drawer-actions"><button :disabled="submitting" @tap="drawer=false">取消</button><button class="approve" :disabled="submitting" @tap="submit">{{ submitting ? '提交中...' : '确认提交' }}</button></view>
+      </view>
+    </view>
+  </view>
+</template>
 
-<style scoped lang="scss">.plan-cover{display:flex;flex-direction:column;align-items:center;padding:40rpx 25rpx;background:linear-gradient(145deg,#f8fbff,#edf5ff)}.file-icon{display:flex;align-items:center;justify-content:center;width:100rpx;height:100rpx;color:#08264c;border-radius:25rpx;background:#fff;box-shadow:0 8rpx 25rpx rgba(8,38,76,.12);font-size:50rpx}.title{margin-top:22rpx;color:#263b52;font-size:32rpx;font-weight:650}.version{margin-top:9rpx;color:#8795a6;font-size:23rpx}.article{padding:7rpx 27rpx;color:#46586d;font-size:26rpx;line-height:1.85}.chapter{padding:20rpx 0;border-bottom:1rpx solid #edf1f5}.chapter:last-child{border-bottom:0}.chapter text{display:block}.chapter text:first-child{margin-bottom:8rpx;color:#2e435a;font-weight:700}.chapter text:last-child{color:#52647a}</style>
+<style scoped lang="scss">
+.state-tip{padding:180rpx 30rpx;color:#8c99a8;text-align:center}
+.plan-cover{display:flex;flex-direction:column;align-items:center;padding:40rpx 25rpx;background:linear-gradient(145deg,#f8fbff,#edf5ff)}
+.file-icon{display:flex;align-items:center;justify-content:center;width:100rpx;height:100rpx;color:#08264c;border-radius:25rpx;background:#fff;box-shadow:0 8rpx 25rpx rgba(8,38,76,.12);font-size:50rpx}
+.title{margin-top:22rpx;color:#263b52;font-size:32rpx;font-weight:650}.version{margin-top:9rpx;color:#8795a6;font-size:23rpx}
+.article{padding:27rpx;color:#46586d;font-size:26rpx;line-height:1.85}
+.has-actions{padding-bottom:150rpx}.version{text-align:center;line-height:1.6}.article{overflow-wrap:anywhere}
+.bottom-actions{position:fixed;right:0;bottom:0;left:0;z-index:30;padding:18rpx 26rpx calc(18rpx + env(safe-area-inset-bottom));background:#fff;box-shadow:0 -5rpx 20rpx rgba(38,59,83,.08)}.bottom-actions button{height:78rpx;color:#fff;border-radius:999rpx;background:#08264c;font-size:26rpx;line-height:78rpx}
+.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:32rpx 32rpx 0 0;background:#fff}.drawer-head{display:flex;align-items:center;justify-content:space-between;height:96rpx;padding:0 30rpx;border-bottom:1rpx solid #edf1f5;color:#122b48;font-size:31rpx;font-weight:700}.close{font-size:42rpx;color:#7f8c9b}.drawer-body{max-height:62vh;padding:5rpx 30rpx 25rpx}.target{display:block;padding:18rpx 0;color:#34485f;font-size:28rpx;font-weight:650}.choice-field{padding:23rpx 0;border-bottom:1rpx solid #edf1f5}.field-label{display:block;margin-bottom:16rpx;color:#34485f;font-size:27rpx}.required-mark{margin-right:6rpx;color:#e34d59}.choice-row{display:grid;grid-template-columns:1fr 1fr;gap:14rpx}.choice-row button{display:flex;align-items:center;justify-content:center;min-height:70rpx;margin:0;padding:8rpx 12rpx;color:#52647a;border:1rpx solid #d9e1ea;border-radius:999rpx;background:#fff;font-size:23rpx;line-height:1.35;text-align:center;word-break:break-word}.choice-row button.active{color:#08264c;border-color:#08264c;background:#eef3f8;font-weight:600}.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;background:#fff}.drawer-actions button{height:80rpx;margin:0;border-radius:999rpx;font-size:27rpx;line-height:80rpx}.drawer-actions button:first-child{color:#43566d;border:1rpx solid #d7e0e9;background:#fff}.drawer-actions .approve{color:#fff;border-color:#08264c;background:#08264c}
+</style>

+ 16 - 3
src/pages/emergency/plans.vue

@@ -1,5 +1,18 @@
 <script setup lang="ts">
-import BusinessListPage from '@/components/BusinessListPage.vue'
-import { plans } from '@/data/mock'
+import { computed, ref } from 'vue'
+import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
+import AppHeader from '@/components/AppHeader.vue'
+import SearchBar from '@/components/SearchBar.vue'
+import BusinessCard from '@/components/BusinessCard.vue'
+import { navigateTo } from '@/utils/navigation'
+import { getEmergencyPlans, type EmergencyPlan } from '@/services/emergency'
+const plans = ref<EmergencyPlan[]>([]), keyword = ref(''), loading = ref(false), page = ref(1), total = ref(0), finished = ref(false), pageSize = 10
+const cards = computed(() => plans.value.filter(item => !keyword.value || `${item.planNo}${item.planName}${item.compileUnit}`.includes(keyword.value)).map(item => ({ id: String(item.id), displayId: item.planNo || '—', title: `${item.planName || item.planNo || '处置方案'}${item.version ? ` · ${item.version}` : ''}`, status: item.planLevelName || '—', tone: 'green' as const, fields: [{ label: '预案类型', value: item.planTypeName || '—' }, { label: '编制单位', value: item.compileUnit || '—' }] })))
+async function load(reset = true) { if (loading.value) return; if (reset) { page.value = 1; finished.value = false }; if (finished.value) return; loading.value = true; try { const result = await getEmergencyPlans({ pageNum: page.value, pageSize, ...(keyword.value ? { keyword: keyword.value } : {}) }); const rows = result.records || []; plans.value = reset ? rows : [...plans.value, ...rows]; total.value = result.total || 0; page.value++; finished.value = plans.value.length >= total.value || rows.length < pageSize } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '处置方案加载失败', icon: 'none' }) } finally { loading.value = false } }
+onLoad(() => load())
+onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh() })
+onReachBottom(() => { if (!loading.value) load(false) })
+function open(item: { id: string }) { navigateTo(`/pages/emergency/plan-detail?id=${item.id}`) }
 </script>
-<template><BusinessListPage title="处置方案" :items="plans" :tabs="['全部','综合预案','专项预案']" detail-path="/pages/emergency/plan-detail" id-label="预案编号" placeholder="请输入预案名称或编号" /></template>
+<template><view class="page"><AppHeader title="处置方案" /><SearchBar v-model="keyword" placeholder="请输入预案名称或编号" @search="load" /><view class="filter-bar">共 {{ total }} 条</view><view class="page-content list-content"><BusinessCard v-for="item in cards" :key="item.id" :item="item" id-label="预案编号" @open="open" /><view v-if="loading" class="empty">正在加载...</view><view v-else-if="!cards.length" class="empty">暂无已通过方案</view><view v-else-if="finished" class="end">没有更多了</view></view></view></template>
+<style scoped lang="scss">.filter-bar{padding:0 26rpx 16rpx;color:#8a97a7;background:#fff;font-size:23rpx}.list-content{padding-top:22rpx}.empty,.end{padding:45rpx 0;color:#9aa6b4;text-align:center}</style>

+ 167 - 49
src/pages/login/index.vue

@@ -2,7 +2,8 @@
 import { onLoad } from '@dcloudio/uni-app'
 import { ref } from 'vue'
 import { getCaptcha, loginAccount } from '@/services/auth'
-import { setToken } from '@/utils/auth'
+import { getToken, setToken } from '@/utils/auth'
+import { useUserStore } from '@/stores/user'
 
 const username = ref('')
 const password = ref('')
@@ -14,6 +15,12 @@ const captchaUrl = ref('')
 const captchaUuid = ref('')
 const captchaLoading = ref(false)
 const loggingIn = ref(false)
+const checkingSession = ref(false)
+const userStore = useUserStore()
+
+// 密码显隐图标(与参考设计一致)
+const eyeOffIcon = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12%207c2.76%200%205%202.24%205%205%200%20.65-.13%201.26-.36%201.83l2.92%202.92c1.51-1.26%202.7-2.89%203.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4%200-2.74.25-3.98.7l2.16%202.16C10.74%207.13%2011.35%207%2012%207zM2%204.27l2.28%202.46.46.46C3.08%208.3%201.78%2010.02%201%2012c1.73%204.39%206%207.5%2011%207.5%201.55%200%203.03-.3%205.03-.83l1.82%201.82%201.27-1.27L3.27%203%202%204.27zM7.53%209.8l1.55%201.55c-.05.21-.08.43-.08.65%200%201.66%201.34%203%203%203%20.22%200%20.27-.03.65-.08l1.55%201.55c-.67.33-1.41.53-2.33.53-2.76%200-5-2.24-5-5%200-.69.2-1.53.53-2.2zm4.31-.78l3.15%203.15.02-.18c0-1.66-1.34-3-3-3l-.17.03z' fill='%2394a3b8'/%3E%3C/svg%3E"
+const eyeOnIcon = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12%204.5C7%204.5%202.73%207.61%201%2012c1.73%204.39%206%207.5%2011%207.5s9.27-3.11%2011-7.5c-1.73-4.39-6-7.5-11-7.5zM12%2017c-2.76%200-5-2.24-5-5s2.24-5%205-5%205%202.24%205%205-2.24%205-5%205zm0-8c-1.66%200-3%201.34-3%203s1.34%203%203%203%203-1.34%203-3-1.34-3-3-3z' fill='%23256399'/%3E%3C/svg%3E"
 
 async function loadCaptcha() {
   if (captchaLoading.value) return
@@ -65,7 +72,26 @@ async function login() {
   }
 }
 
-onLoad(() => {
+function showForgot() {
+  uni.showToast({ title: '请联系系统管理员重置密码', icon: 'none' })
+}
+
+onLoad(async () => {
+  // H5 的根路径默认落到 pages/login/index(它是 pages.json 的首个页面)。
+  // 如果本地仍有有效令牌,先重新获取用户信息确认会话未失效,再进入应用首页,
+  // 避免刷新 http://localhost:5174/ 后再次显示登录页。
+  if (getToken()) {
+    checkingSession.value = true
+    try {
+      await userStore.fetchProfile()
+      uni.switchTab({ url: '/pages/message/index' })
+      return
+    } catch {
+      // 令牌过期时 requestApi 会清理会话并保持在登录页,继续正常登录流程。
+    } finally {
+      checkingSession.value = false
+    }
+  }
   const rememberedUsername = uni.getStorageSync('remembered-username')
   if (rememberedUsername) {
     username.value = rememberedUsername
@@ -76,63 +102,155 @@ onLoad(() => {
 </script>
 
 <template>
+  <view v-if="checkingSession" class="session-check">正在进入消息主页...</view>
   <view class="login-page">
-    <view class="network-lines"><view v-for="i in 8" :key="i" class="line" :style="{ top: `${i * 11}%`, transform: `rotate(${i % 2 ? -8 : 9}deg)` }"></view></view>
-    <view class="login-wrap">
-      <view class="brand">
-        <view class="brand-icon"><view class="pipe-mark">⌁</view></view>
-        <view class="brand-title">沅陵太常片区城市地下管网</view>
-        <view class="brand-title">管控平台</view>
+    <view class="banner">
+      <view class="banner-deco">YL</view>
+      <view class="banner-title">您好,欢迎使用<text class="banner-sub">沅陵县城市地下管网管控平台</text></view>
+    </view>
+
+    <view class="card">
+      <view class="field">
+        <text class="field-label">账户</text>
+        <view class="ipt-wrap">
+          <input
+            class="ipt"
+            v-model="username"
+            type="text"
+            autocomplete="username"
+            placeholder="请输入用户名/手机号"
+            placeholder-style="color:#b6c2d4"
+          />
+        </view>
       </view>
-      <view class="login-card">
-        <view class="welcome">欢迎登录 <text>管网管控平台</text></view>
-        <view class="input-row"><view class="icon user-icon" aria-hidden="true"></view><input v-model="username" placeholder="请输入账号" /></view>
-        <view class="input-row"><view class="icon lock-icon" aria-hidden="true"></view><input v-model="password" placeholder="请输入密码" :password="!showPassword" /><text class="eye" @tap="showPassword = !showPassword">{{ showPassword ? '◉' : '◌' }}</text></view>
-        <view v-if="captchaEnabled" class="captcha-row">
-          <view class="input-row"><view class="icon verify-icon" aria-hidden="true"></view><input v-model="captcha" maxlength="4" placeholder="验证码" /></view>
+
+      <view class="field">
+        <text class="field-label">密码</text>
+        <view class="ipt-wrap">
+          <input
+            class="ipt"
+            v-model="password"
+            :password="!showPassword"
+            autocomplete="current-password"
+            placeholder="请输入密码"
+            placeholder-style="color:#b6c2d4"
+          />
+          <view class="eye-btn" @tap="showPassword = !showPassword" aria-label="显示/隐藏密码">
+            <image class="eye-img" :src="showPassword ? eyeOnIcon : eyeOffIcon" mode="aspectFit" />
+          </view>
+        </view>
+      </view>
+
+      <view v-if="captchaEnabled" class="field">
+        <text class="field-label">验证码</text>
+        <view class="ipt-wrap code-wrap">
+          <input
+            class="ipt"
+            v-model="captcha"
+            type="text"
+            autocomplete="off"
+            maxlength="4"
+            placeholder="请输入验证码"
+            placeholder-style="color:#b6c2d4"
+          />
           <view class="captcha" @tap="loadCaptcha">
             <image v-if="captchaUrl" class="captcha-image" :src="captchaUrl" mode="scaleToFill" />
-            <text v-else>{{ captchaLoading ? '加载中...' : '点击刷新' }}</text>
+            <text v-else class="captcha-tip">{{ captchaLoading ? '加载中...' : '点击刷新' }}</text>
           </view>
         </view>
-        <view class="remember" @tap="remember = !remember"><view class="check" :class="{ checked: remember }">{{ remember ? '✓' : '' }}</view><text>记住账号</text></view>
-        <button class="login-btn" :class="{ disabled: loggingIn }" :loading="loggingIn" :disabled="loggingIn" @tap="login">{{ loggingIn ? '登录中...' : '登 录' }}</button>
       </view>
-      <text class="copyright">城市地下管网安全运行 · 智慧守护</text>
+
+      <view class="row">
+        <view class="chk" @tap="remember = !remember">
+          <view class="chk-box" :class="{ checked: remember }">{{ remember ? '✓' : '' }}</view>
+          <text class="chk-text">记住密码</text>
+        </view>
+        <text class="link" @tap="showForgot">忘记密码?</text>
+      </view>
+
+      <button class="btn btn-primary" :loading="loggingIn" :disabled="loggingIn" @tap="login">
+        {{ loggingIn ? '登录中...' : '登录' }}
+      </button>
     </view>
+
+    <view class="footer">汇聚地下管网运行态势 · 统览城市生命线安全</view>
   </view>
 </template>
 
 <style scoped lang="scss">
-.login-page, .login-wrap, .login-card, .input-row, .captcha-row { box-sizing: border-box; }
-.login-page { position: relative; width: 100%; min-height: 100vh; overflow: hidden; color: #fff; background: linear-gradient(160deg, #16324f 0%, #1f4e79 48%, #2b6ca3 100%); }
-.network-lines { position: absolute; inset: 0; opacity: .12; }
-.line { position: absolute; left: -20%; width: 140%; height: 1rpx; background: repeating-linear-gradient(90deg, #fff 0, #fff 35rpx, transparent 35rpx, transparent 62rpx); }
-.login-wrap { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; width: 100%; min-height: 100vh; padding: calc(120rpx + env(safe-area-inset-top)) 55rpx 40rpx; }
-.brand { width: 100%; text-align: center; text-shadow: 0 4rpx 16rpx rgba(10,30,50,.45); }
-.brand-icon { display: flex; align-items: center; justify-content: center; width: 128rpx; height: 128rpx; margin: 0 auto 28rpx; border-radius: 34rpx; background: rgba(255,255,255,.96); box-shadow: 0 16rpx 40rpx rgba(8,26,44,.35); }
-.pipe-mark { color: #2563eb; font-size: 82rpx; font-weight: 600; transform: rotate(-18deg); }
-.brand-title { font-size: 38rpx; font-weight: 600; letter-spacing: 2rpx; line-height: 1.55; }
-.login-card { width: 100%; max-width: 640rpx; margin-top: 70rpx; padding: 50rpx 40rpx 56rpx; border-radius: 28rpx; background: #fff; box-shadow: 0 28rpx 70rpx rgba(6,22,38,.4); }
-.welcome { margin-bottom: 40rpx; color: #26374e; font-size: 31rpx; font-weight: 600; text-align: center; }
-.welcome text { color: #2563eb; }
-.input-row { display: flex; flex: 1; align-items: center; height: 94rpx; padding: 0 24rpx; margin-bottom: 24rpx; border: 1rpx solid #dfe7ef; border-radius: 18rpx; background: #f8fafc; }
-.icon { position: relative; display: flex; flex: 0 0 54rpx; align-items: center; justify-content: center; width: 54rpx; height: 54rpx; margin-right: 17rpx; border: 1rpx solid #c8dced; border-radius: 16rpx; background: linear-gradient(145deg, #edf6ff, #dcecff); color: #164c7f; }
-.user-icon::before { content: ''; position: absolute; top: 11rpx; width: 15rpx; height: 15rpx; border: 3rpx solid #164c7f; border-radius: 50%; }
-.user-icon::after { content: ''; position: absolute; bottom: 10rpx; width: 29rpx; height: 15rpx; border: 3rpx solid #164c7f; border-bottom: 0; border-radius: 18rpx 18rpx 0 0; }
-.lock-icon::before { content: ''; position: absolute; bottom: 11rpx; width: 26rpx; height: 21rpx; border: 3rpx solid #164c7f; border-radius: 5rpx; background: rgba(255,255,255,.3); }
-.lock-icon::after { content: ''; position: absolute; top: 12rpx; width: 17rpx; height: 18rpx; border: 3rpx solid #164c7f; border-bottom: 0; border-radius: 12rpx 12rpx 0 0; }
-.verify-icon::before { content: ''; position: absolute; width: 27rpx; height: 27rpx; border: 3rpx solid #164c7f; border-radius: 50%; }
-.verify-icon::after { content: ''; position: absolute; width: 13rpx; height: 7rpx; border-bottom: 3rpx solid #164c7f; border-left: 3rpx solid #164c7f; transform: translate(1rpx, -2rpx) rotate(-45deg); }
-input { flex: 1; color: #293a50; font-size: 28rpx; }
-.eye { padding: 18rpx 0 18rpx 20rpx; color: #8596a9; }
-.captcha-row { display: grid; grid-template-columns: 1fr 190rpx; gap: 18rpx; }
-.captcha { display: flex; align-items: center; justify-content: center; height: 94rpx; color: #1d4f88; border-radius: 16rpx; background: repeating-linear-gradient(15deg,#e9f3ff,#e9f3ff 7rpx,#d7e9fb 8rpx,#d7e9fb 10rpx); font-size: 31rpx; font-weight: 700; letter-spacing: 6rpx; font-style: italic; }
-.captcha-image { width: 100%; height: 100%; border-radius: 16rpx; }
-.remember { display: flex; align-items: center; width: max-content; margin: 2rpx 0 35rpx; color: #697b90; font-size: 25rpx; }
-.check { display: flex; align-items: center; justify-content: center; width: 31rpx; height: 31rpx; margin-right: 12rpx; border: 1rpx solid #b8c3d0; border-radius: 6rpx; }
-.check.checked { color: #fff; border-color: #2563eb; background: #2563eb; }
-.login-btn { height: 92rpx; color: #fff; border-radius: 16rpx; background: linear-gradient(135deg,#2479ec,#1858cc); box-shadow: 0 12rpx 26rpx rgba(37,99,235,.3); font-size: 31rpx; line-height: 92rpx; letter-spacing: 12rpx; }
-.login-btn.disabled { opacity: .7; }
-.copyright { margin-top: auto; padding-top: 40rpx; color: rgba(255,255,255,.68); font-size: 23rpx; }
+.login-page {
+  position: relative; width: 100%; min-height: 100vh; min-height: 100dvh; overflow-x: hidden;
+  color: #16233b; background: #f5f7fb;
+  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif;
+}
+.session-check {
+  position: fixed; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center;
+  color: #52647a; background: #f5f7fb; font-size: 30rpx;
+}
+.banner {
+  position: relative; overflow: hidden;
+  background: linear-gradient(180deg, #1d4f80 0%, #256399 55%, #2a73a8 100%);
+  padding: calc(140rpx + env(safe-area-inset-top, 0px)) 64rpx 240rpx;
+}
+.banner::before {
+  content: ''; position: absolute; right: -120rpx; top: 30%;
+  width: 440rpx; height: 440rpx; border-radius: 50%;
+  background: radial-gradient(circle, rgba(255, 255, 255, .18), transparent 70%);
+  filter: blur(12rpx);
+}
+.banner-deco {
+  position: absolute; right: 56rpx; top: 50%; transform: translateY(-50%);
+  font-size: 192rpx; font-weight: 900; color: #fff; opacity: .18;
+  letter-spacing: -12rpx; line-height: 1; z-index: 0; user-select: none;
+}
+.banner-title {
+  position: relative; z-index: 1;
+  font-size: 52rpx; font-weight: 700; color: #fff; line-height: 1.5; letter-spacing: 2rpx;
+}
+.banner-sub {
+  display: block; font-size: 36rpx; font-weight: 500; margin-top: 12rpx;
+  letter-spacing: 2rpx; opacity: .92;
+}
+.card {
+  position: relative; z-index: 1;
+  margin: -180rpx 44rpx 48rpx;
+  background: #fff; border-radius: 40rpx;
+  box-shadow: 0 32rpx 72rpx rgba(20, 40, 80, .10);
+  padding: 60rpx 48rpx 68rpx;
+}
+.field { margin-bottom: 36rpx; }
+.field-label { display: block; font-size: 26rpx; font-weight: 600; color: #16233b; margin-bottom: 20rpx; }
+.ipt-wrap { position: relative; z-index: 2; display: flex; align-items: center; border-bottom: 1rpx solid #e6ecf2; transition: border-color .2s; }
+.ipt-wrap:focus-within { border-color: #256399; }
+.ipt { flex: 1; width: 100%; min-height: 72rpx; border: none; outline: none; background: transparent; padding: 20rpx 0; font-size: 30rpx; color: #16233b; }
+.code-wrap { display: flex; align-items: center; gap: 16rpx; }
+.eye-btn { position: absolute; right: 0; top: 50%; transform: translateY(-50%); padding: 12rpx 8rpx; }
+.eye-img { width: 40rpx; height: 40rpx; display: block; }
+.captcha {
+  display: flex; align-items: center; justify-content: center;
+  height: 64rpx; padding: 0; border-radius: 10rpx; overflow: hidden;
+}
+.captcha-image { width: 160rpx; height: 64rpx; border-radius: 10rpx; }
+.captcha-tip { font-size: 24rpx; font-weight: 400; color: #94a3b8; }
+.row { display: flex; align-items: center; justify-content: space-between; margin: 12rpx 0 44rpx; font-size: 24rpx; color: #94a3b8; }
+.chk { display: flex; align-items: center; gap: 12rpx; }
+.chk-box {
+  display: flex; align-items: center; justify-content: center;
+  width: 28rpx; height: 28rpx; border: 1rpx solid #b8c3d0; border-radius: 6rpx;
+  color: #fff; font-size: 22rpx;
+}
+.chk-box.checked { border-color: #256399; background: #256399; }
+.link { color: #94a3b8; }
+.link:active { color: #256399; }
+.btn { width: 100%; height: 96rpx; border: none; border-radius: 48rpx; font-size: 32rpx; font-weight: 600; }
+.btn-primary { background: linear-gradient(90deg, #1d4f80, #256399); color: #fff; box-shadow: 0 20rpx 44rpx rgba(37, 99, 153, .32); }
+.btn-primary:active { opacity: .92; }
+.footer {
+  text-align: center; padding: 60rpx 48rpx;
+  font-size: 28rpx; line-height: 1.9; color: #1d4f80; font-weight: 600; letter-spacing: 2rpx;
+}
+.footer::before {
+  content: ''; display: block; width: 72rpx; height: 4rpx; margin: 0 auto 28rpx; border-radius: 2rpx;
+  background: linear-gradient(90deg, #1d4f80, #256399);
+}
 </style>

+ 14 - 1
src/pages/message/index.vue

@@ -8,6 +8,7 @@ import { getNoticeList, getNoticeTypeLabel, type SystemNotice } from '@/services
 import { getMobilePendingWorkOrders, workOrderStatusText, workOrderTone, type WorkOrder } from '@/services/workorders'
 import { getMobilePendingWarnings, getWarningRevision, warningStatusText, warningStatusTone, type EarlyWarning } from '@/services/warnings'
 import { navigateTo } from '@/utils/navigation'
+import { useUserStore } from '@/stores/user'
 
 const active = ref(0)
 const notices = ref<SystemNotice[]>([])
@@ -25,10 +26,22 @@ const pendingHasMore = computed(() => pendingOrders.value.length < pendingTotal.
 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)
+const userStore = useUserStore()
+const hour = ref(new Date().getHours())
+const greeting = computed(() => {
+  if (hour.value < 6) return '凌晨好'
+  if (hour.value < 12) return '早上好'
+  if (hour.value < 14) return '中午好'
+  if (hour.value < 18) return '下午好'
+  return '晚上好'
+})
+const userName = computed(() => userStore.profile?.nickName || userStore.profile?.userName || '')
 
 async function refresh() { await Promise.all([loadPendingOrders(true), loadPendingWarnings(true), loadNotices()]) }
 let seenWarningRevision = -1
 onShow(() => {
+  hour.value = new Date().getHours()
+  if (!userStore.profile) void userStore.fetchProfile().catch(() => undefined)
   const revision = getWarningRevision()
   if (seenWarningRevision < 0) { seenWarningRevision = revision; refresh() }
   else if (seenWarningRevision !== revision) { seenWarningRevision = revision; loadPendingWarnings(true) }
@@ -106,7 +119,7 @@ function openAll() { navigateTo(active.value === 0 ? '/pages/profile/workorders'
 <template>
   <view class="page">
     <view class="hero">
-      <view class="hero-top"><view><text class="hello">早上好,管理员</text><text class="sub">城市管网运行总体平稳</text></view></view>
+      <view class="hero-top"><view><text class="hello">{{ greeting }},{{ userName }}</text><text class="sub">城市管网运行总体平稳</text></view></view>
       <view class="notice">
         <view class="bell-icon"></view>
         <swiper v-if="notices.length" class="notice-swiper" vertical circular :autoplay="notices.length > 1" :interval="3500" :duration="450">

+ 2 - 2
src/services/auth.ts

@@ -43,8 +43,8 @@ export interface UserProfile {
 
 export interface UserInfoResponse extends ApiResponse {
   user: UserProfile
-  roles?: string[]
-  permissions?: string[]
+  roles?: unknown
+  permissions?: unknown
 }
 
 export function getCaptcha() {

+ 136 - 0
src/services/emergency.ts

@@ -0,0 +1,136 @@
+import { ApiError, getApiUrl, parseApiResponse, requestApi, type ApiResponse } from './api'
+import { getToken } from '@/utils/auth'
+
+export interface EmergencyEvent {
+  id: number
+  eventNo?: string
+  description?: string
+  eventSource?: string
+  eventSourceName?: string
+  channel?: string
+  channelName?: string
+  location?: string
+  longitude?: number
+  latitude?: number
+  happenTime?: string
+  reportTime?: string
+  reporter?: string
+  reporterPhone?: string
+  eventCategory?: string
+  eventCategoryName?: string
+  eventLevel?: string
+  eventLevelName?: string
+  status?: number
+  statusName?: string
+  verifyResult?: number
+  verifier?: string
+  verifierId?: number
+  handler?: string
+  handlerId?: number
+  disposeTime?: string
+  releaseTime?: string
+  releaseReason?: string
+  releaseReasonName?: string
+  releaseNote?: string
+  createTime?: string
+  logs?: EmergencyLog[]
+  attachmentBaseUrl?: string
+}
+
+export interface EmergencyLog {
+  id?: number
+  operType?: number
+  operTypeName?: string
+  operDesc?: string
+  operUserName?: string
+  operTime?: string
+  attachUrl?: string
+  targets?: string
+  trend?: string
+  trendName?: string
+  needUpgrade?: number
+  needUpgradeName?: string
+  nextVisitTime?: string
+}
+
+export function splitEmergencyAttachments(value?: string) {
+  return value ? value.split(',').map(item => item.trim()).filter(Boolean) : []
+}
+
+export function resolveEmergencyAttachment(path: string, baseUrl?: string) {
+  if (/^(https?:|data:|blob:)/.test(path)) return path
+  return `${(baseUrl || '').replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
+}
+
+export interface EmergencyPlan {
+  id: number
+  planNo?: string
+  planName?: string
+  planType?: string
+  planTypeName?: string
+  planLevel?: string
+  planLevelName?: string
+  compileUnit?: string
+  publishUnit?: string
+  publishDate?: string
+  status?: number
+  statusName?: string
+  content?: string
+  version?: string
+  createTime?: string
+  approveTime?: string
+  approveNote?: string
+}
+
+export interface PageResult<T> { records: T[]; total: number; current: number; size: number; pages?: number }
+export interface EmergencyOption { value: string; label: string }
+export interface UserOption { userId: number; nickName: string; deptId?: number; deptName?: string }
+interface DataResponse<T> extends ApiResponse { data: T }
+
+export function eventStatusCode(status: unknown): number {
+  const numeric = Number(status)
+  if (Number.isFinite(numeric)) return numeric
+  return ({ PENDING_VERIFY: 0, VERIFIED: 1, DISPOSING: 2, PENDING_ACCEPTANCE: 3, RELEASED: 4, FALSE_EVENT: 5, '待核实': 0, '已核实': 1, '处置中': 2, '待验收': 3, '已解除': 4, '不实事件': 5 } as Record<string, number>)[String(status || '').trim().toUpperCase()] ?? -1
+}
+export function planStatusCode(status: unknown): number {
+  const numeric = Number(status)
+  if (Number.isFinite(numeric)) return numeric
+  return ({ DRAFT: 0, PENDING: 1, PENDING_APPROVAL: 1, APPROVED: 2, REJECTED: 3, '草稿': 0, '待审核': 1, '已通过': 2, '已驳回': 3 } as Record<string, number>)[String(status || '').trim().toUpperCase()] ?? -1
+}
+export const eventStatusText = (status?: unknown) => ({ 0: '待核实', 1: '已核实', 2: '处置中', 3: '待验收', 4: '已解除', 5: '不实事件' } as Record<number, string>)[eventStatusCode(status)] || '未知'
+export const eventStatusTone = (status?: unknown) => ({ 0: 'orange', 1: 'blue', 2: 'purple', 3: 'teal', 4: 'green', 5: 'gray' } as Record<number, string>)[eventStatusCode(status)] || ''
+export const planStatusText = (status?: unknown) => ({ 0: '草稿', 1: '待审核', 2: '已通过', 3: '已驳回' } as Record<number, string>)[planStatusCode(status)] || '未知'
+export const planStatusTone = (status?: unknown) => ({ 0: 'gray', 1: 'orange', 2: 'green', 3: 'red' } as Record<number, string>)[planStatusCode(status)] || ''
+
+export function getEmergencyHome() { return requestApi<DataResponse<{ pendingCount: number; disposingCount: number; releasedCount: number; dynamics: EmergencyEvent[] }>>('/api/mobile/emergency/home').then(r => r.data) }
+export function getEmergencyEvents(query: Record<string, unknown> = {}) { return requestApi<DataResponse<PageResult<EmergencyEvent>>>('/api/mobile/emergency/events', { data: query }).then(r => r.data) }
+export function getEmergencyEvent(id: number) { return requestApi<DataResponse<EmergencyEvent>>(`/api/mobile/emergency/events/${id}`).then(r => r.data) }
+export function verifyEmergencyEvent(id: number, data: Record<string, unknown>) { return requestApi<ApiResponse>(`/api/mobile/emergency/events/${id}/verify`, { method: 'POST', data }) }
+export function startEmergencyEvent(id: number, data: Record<string, unknown>) { return requestApi<ApiResponse>(`/api/mobile/emergency/events/${id}/start`, { method: 'POST', data }) }
+export function trackEmergencyEvent(id: number, data: Record<string, unknown>) { return requestApi<ApiResponse>(`/api/mobile/emergency/events/${id}/track`, { method: 'POST', data }) }
+export function transferEmergencyEvent(id: number, data: Record<string, unknown>) { return requestApi<ApiResponse>(`/api/mobile/emergency/events/${id}/transfer`, { method: 'POST', data }) }
+export function reportEmergencyEvent(id: number, data: Record<string, unknown>) { return requestApi<ApiResponse>(`/api/mobile/emergency/events/${id}/report`, { method: 'POST', data }) }
+export function submitVerifyEmergencyEvent(id: number, data: Record<string, unknown>) { return requestApi<ApiResponse>(`/api/mobile/emergency/events/${id}/submit-verify`, { method: 'POST', data }) }
+export function auditEmergencyEvent(id: number, data: Record<string, unknown>) { return requestApi<ApiResponse>(`/api/mobile/emergency/events/${id}/audit`, { method: 'POST', data }) }
+export function getEmergencyDict(type: string) { return requestApi<DataResponse<Array<{ dictValue: string; dictLabel: string }>>>(`/system/dict/data/type/${type}`).then(r => (r.data || []).map(item => ({ value: item.dictValue, label: item.dictLabel }))) }
+export function getEmergencyHandlerOptions() { return requestApi<DataResponse<UserOption[]>>('/api/dispatch/event/handlerOptions').then(r => r.data || []) }
+export function getEmergencyVerifierOptions() { return requestApi<DataResponse<UserOption[]>>('/api/dispatch/event/verifierOptions').then(r => r.data || []) }
+export function getEmergencyPlans(query: Record<string, unknown> = {}) { return requestApi<DataResponse<PageResult<EmergencyPlan>>>('/api/mobile/emergency/plans', { data: query }).then(r => r.data) }
+export function getEmergencyPlan(id: number) { return requestApi<DataResponse<EmergencyPlan>>(`/api/mobile/emergency/plans/${id}`).then(r => r.data) }
+export function getEmergencyAudit(query: Record<string, unknown> = {}) { return requestApi<DataResponse<PageResult<EmergencyPlan>>>('/api/mobile/emergency/audit', { data: query }).then(r => r.data) }
+export function reviewEmergencyPlan(id: number, approved: boolean, approveNote = '') { return requestApi<ApiResponse>(`/api/mobile/emergency/audit/${id}`, { method: 'POST', data: { approved, approveNote } }) }
+
+export function uploadEmergencyFile(filePath: string): Promise<string> {
+  return new Promise((resolve, reject) => uni.uploadFile({
+    url: `${getApiUrl('/minio/upload')}?dir=dispatch`, filePath, name: 'file',
+    header: { Authorization: `Bearer ${getToken()}` }, timeout: 30000,
+    success(response) {
+      try {
+        const result = parseApiResponse<ApiResponse & { relativePath?: string }>(response.statusCode, response.data)
+        if (result.relativePath) resolve(result.relativePath)
+        else reject(new ApiError(result.msg || result.message || '附件上传失败'))
+      } catch (error) { reject(error instanceof Error ? error : new ApiError('附件上传响应异常')) }
+    },
+    fail(error) { reject(new ApiError(error.errMsg?.includes('timeout') ? '附件上传超时' : '附件上传失败')) },
+  }))
+}

+ 30 - 2
src/stores/user.ts

@@ -1,6 +1,18 @@
 import { reactive } from 'vue'
 import { getInfo, type UserProfile } from '@/services/auth'
 
+function normalizeValues(value: unknown): string[] {
+  if (Array.isArray(value)) return value.flatMap(item => normalizeValues(item))
+  if (typeof value === 'string') return value.split(',').map(item => item.trim()).filter(Boolean)
+  if (value && typeof value === 'object') {
+    const item = value as Record<string, unknown>
+    for (const key of ['permission', 'permissions', 'perms', 'roleKey', 'roleName', 'key', 'value', 'name']) {
+      if (key in item) return normalizeValues(item[key])
+    }
+  }
+  return []
+}
+
 const userStore = reactive({
   profile: null as UserProfile | null,
   roles: [] as string[],
@@ -8,8 +20,11 @@ const userStore = reactive({
   async fetchProfile() {
     const response = await getInfo()
     userStore.profile = response.user
-    userStore.roles = response.roles || []
-    userStore.permissions = response.permissions || []
+    // The web API normally serializes these as Sets/arrays.  Keep the mobile
+    // store tolerant of a comma-delimited value as well, otherwise a valid
+    // permission response can silently make every operation button disappear.
+    userStore.roles = [...normalizeValues(response.roles), ...normalizeValues(response.user?.roles)]
+    userStore.permissions = normalizeValues(response.permissions)
     return response.user
   },
   clearProfile() {
@@ -22,3 +37,16 @@ const userStore = reactive({
 export function useUserStore() {
   return userStore
 }
+
+/** Shared permission check for mobile operation buttons. */
+export function hasUserPermission(permission: string) {
+  const wanted = permission.trim().toLowerCase()
+  const permissions = userStore.permissions.map(item => item.trim().toLowerCase())
+  const roles = userStore.roles.map(role => role.toLowerCase())
+  return permissions.some(item => item === '*:*:*' || item === '*' || item === wanted
+    || (item.endsWith(':*') && wanted.startsWith(item.slice(0, -1))))
+    || roles.includes('admin')
+    || roles.includes('administrator')
+    || roles.includes('role_admin')
+    || Number(userStore.profile?.userId) === 1
+}

+ 1 - 0
src/styles/global.scss

@@ -70,6 +70,7 @@ button::after { border: 0; }
 }
 .status-pill.red { color: #d9363e; background: #fff0f0; }
 .status-pill.orange { color: #d66a06; background: #fff5e6; }
+.status-pill.teal { color: #087f8c; background: #e6f7f8; }
 .status-pill.green { color: #17875b; background: #e8f8ef; }
 .status-pill.gray { color: #718096; background: #eef2f6; }
 .status-pill.purple { color: #7057c7; background: #f0edff; }

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio