feat: UI rewrite R6 - final round (10 pages)

- fxgl: groupsClock/teamList/instructionList/receive/materialList/inventory (6)
- xjgl: constructionList/detail (2)
- map: tckzPop/ybPop redesign (2)
All 63 pages now use consistent design system
This commit is contained in:
2026-06-15 23:06:46 +08:00
parent ef44f6dc25
commit f44c9bff49
10 changed files with 2206 additions and 648 deletions

View File

@@ -1,87 +1,150 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 班组人员打卡列表 * 班组人员打卡列表 — 防汛值班打卡记录
* *
* 展示防汛值班人员的打卡记录, * 展示防汛值班人员的打卡记录,支持按班组切换和下拉刷新。
* 支持按班组切换和日期筛选。 * DESIGN: #1E74FF NavBar, #F4F7F8 bg, white cards border-radius 10px.
*/ */
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from 'vant'
const router = useRouter() const router = useRouter()
// ── 状态 ──
const activeTab = ref(0) const activeTab = ref(0)
const dateFilter = ref('') const loading = ref(false)
const refreshing = ref(false)
/** 打卡状态映射 */ // ── 映射 ──
const statusMap: Record<string, string> = { interface ClockRecord {
id: number
name: string
team: string
time: string
status: 'clocked' | 'late' | 'missing'
location: string
avatar?: string
}
const statusMap: Record<ClockRecord['status'], string> = {
clocked: '已打卡', clocked: '已打卡',
late: '迟到', late: '迟到',
missing: '缺卡', missing: '缺卡',
} }
const statusColorMap: Record<string, 'success' | 'warning' | 'danger'> = { const statusColorMap: Record<ClockRecord['status'], 'success' | 'warning' | 'danger'> = {
clocked: 'success', clocked: 'success',
late: 'warning', late: 'warning',
missing: 'danger', missing: 'danger',
} }
/** 模拟打卡数据 */ const teamTabs = ['全部', 'A组', 'B组', 'C组']
const mockRecords = [
// ── 数据 ──
const records = ref<ClockRecord[]>([])
const mockRecords: ClockRecord[] = [
{ id: 1, name: '张建国', team: 'A组', time: '08:25', status: 'clocked', location: '泵站1号' }, { id: 1, name: '张建国', team: 'A组', time: '08:25', status: 'clocked', location: '泵站1号' },
{ id: 2, name: '李明辉', team: 'A组', time: '08:32', status: 'late', location: '泵站1号' }, { id: 2, name: '李明辉', team: 'A组', time: '08:32', status: 'late', location: '泵站1号' },
{ id: 3, name: '王强', team: 'B组', time: '08:15', status: 'clocked', location: '闸门3号' }, { id: 3, name: '王强', team: 'B组', time: '08:15', status: 'clocked', location: '闸门3号' },
{ id: 4, name: '赵勇', team: 'B组', time: '--:--', status: 'missing', location: '闸门3号' }, { id: 4, name: '赵勇', team: 'B组', time: '--:--', status: 'missing', location: '闸门3号' },
{ id: 5, name: '陈志远', team: 'A组', time: '08:20', status: 'clocked', location: '河道巡查点' }, { id: 5, name: '陈志远', team: 'A组', time: '08:20', status: 'clocked', location: '河道巡查点' },
{ id: 6, name: '周文博', team: 'C组', time: '08:10', status: 'clocked', location: '泵站2号' },
{ id: 7, name: '刘大伟', team: 'C组', time: '08:45', status: 'late', location: '泵站2号' },
{ id: 8, name: '孙志强', team: 'B组', time: '08:18', status: 'clocked', location: '河道巡查点' },
] ]
// ── 计算属性 ──
const filteredList = computed(() => { const filteredList = computed(() => {
let list = mockRecords if (activeTab.value === 0) return records.value
if (activeTab.value === 1) list = list.filter(r => r.team === 'A组') const team = teamTabs[activeTab.value]
else if (activeTab.value === 2) list = list.filter(r => r.team === 'B组') return records.value.filter(r => r.team === team)
return list
}) })
// ── 方法 ──
async function fetchData() {
loading.value = true
// 模拟异步请求
await new Promise(resolve => setTimeout(resolve, 400))
records.value = mockRecords
loading.value = false
}
async function onRefresh() {
refreshing.value = true
await new Promise(resolve => setTimeout(resolve, 600))
records.value = [...mockRecords].reverse()
refreshing.value = false
showToast('刷新成功')
}
function goTeamList() { function goTeamList() {
router.push('/teamList') router.push('/teamList')
} }
onMounted(() => {
fetchData()
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- 导航栏 -->
<van-nav-bar title="打卡记录" left-arrow fixed placeholder @click-left="router.back()"> <van-nav-bar title="打卡记录" left-arrow fixed placeholder @click-left="router.back()">
<template #right> <template #right>
<van-icon name="user-o" size="20" @click="goTeamList" /> <van-icon name="user-o" size="20" @click="goTeamList" />
</template> </template>
</van-nav-bar> </van-nav-bar>
<van-tabs v-model:active="activeTab" sticky> <!-- 标签页 -->
<van-tab title="全部" /> <van-tabs v-model:active="activeTab" sticky :swipeable="false" color="var(--color-primary)">
<van-tab title="A组" /> <van-tab v-for="name in teamTabs" :key="name" :title="name" />
<van-tab title="B组" />
</van-tabs> </van-tabs>
<div class="card-list"> <!-- 下拉刷新 + 列表 -->
<van-empty v-if="filteredList.length === 0" description="暂无打卡记录" /> <van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="pull-refresh-wrap">
<van-card <!-- 加载骨架 -->
v-for="item in filteredList" <div v-if="loading" class="skeleton-wrap">
:key="item.id" <div v-for="i in 4" :key="i" class="skeleton-card">
:title="item.name" <van-skeleton title avatar :row="1" />
:desc="`点位: ${item.location}`" </div>
> </div>
<template #tags>
<van-tag :type="statusColorMap[item.status]" size="medium"> <!-- 空状态 -->
{{ statusMap[item.status] }} <van-empty v-else-if="filteredList.length === 0" description="暂无打卡记录">
</van-tag> <van-button type="primary" size="small" @click="onRefresh">重新加载</van-button>
</template> </van-empty>
<template #footer>
<div class="card-meta"> <!-- 打卡卡片列表 -->
<span>打卡时间: {{ item.time }}</span> <div v-else class="card-list">
<span>{{ item.team }}</span> <div
v-for="item in filteredList"
:key="item.id"
class="clock-card"
>
<div class="card-hd">
<span class="card-name">{{ item.name }}</span>
<van-tag :type="statusColorMap[item.status]" size="medium">
{{ statusMap[item.status] }}
</van-tag>
</div> </div>
</template> <div class="card-bd">
</van-card> <div class="card-info">
</div> <van-icon name="location-o" size="14" />
<span>{{ item.location }}</span>
</div>
<div class="card-info">
<van-icon name="clock-o" size="14" />
<span>打卡时间: {{ item.time }}</span>
</div>
</div>
<div class="card-ft">
<span class="card-team">{{ item.team }}</span>
</div>
</div>
</div>
</van-pull-refresh>
</div> </div>
</template> </template>
@@ -98,20 +161,78 @@ function goTeamList() {
} }
} }
.card-list { .pull-refresh-wrap {
padding: 0 8px; min-height: calc(100vh - 90px);
}
:deep(.van-card) { // ── 骨架屏 ──
margin: 8px; .skeleton-wrap {
border-radius: 10px; padding: 8px 16px;
background: var(--color-bg-card); }
.skeleton-card {
padding: 16px;
margin-bottom: 8px;
background: var(--color-bg-card);
border-radius: 10px;
}
// ── 卡片列表 ──
.card-list {
padding: 8px 12px;
}
.clock-card {
background: var(--color-bg-card);
border-radius: 10px;
padding: 16px;
margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
.card-hd {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.card-name {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.card-bd {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.card-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-secondary);
}
.card-ft {
padding-top: 8px;
border-top: 1px solid var(--color-border-light);
}
.card-team {
font-size: 12px;
color: var(--color-text-placeholder);
background: var(--color-primary-bg);
color: var(--color-primary);
padding: 2px 8px;
border-radius: 4px;
} }
} }
.card-meta { // ── 空状态 ──
display: flex; :deep(.van-empty) {
gap: 12px; padding: 60px 0;
font-size: 12px;
color: var(--color-text-secondary);
} }
</style> </style>

View File

@@ -1,52 +1,74 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 防汛指令列表 * 防汛指令列表 — 接收与处理防汛指令
* *
* 展示下发的防汛指令,支持按状态筛选搜索, * 展示下发的防汛指令,支持按状态筛选搜索和下拉刷新
* 点击可查看指令详情并处理 * 每条指令显示级别标签和发布时间
* DESIGN: #1E74FF NavBar, #F4F7F8 bg, white cards border-radius 10px.
*/ */
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from 'vant'
const router = useRouter() const router = useRouter()
// ── 类型 ──
type InstructionStatus = 'pending' | 'accepted' | 'completed'
type InstructionLevel = 'urgent' | 'normal'
interface Instruction {
id: number
title: string
level: InstructionLevel
from: string
time: string
status: InstructionStatus
}
// ── 状态 ──
const searchText = ref('') const searchText = ref('')
const activeTab = ref(0) const activeTab = ref(0)
const loading = ref(false)
const refreshing = ref(false)
/** 状态映射 */ // ── 映射 ──
const statusMap: Record<string, string> = { const statusMap: Record<InstructionStatus, string> = {
pending: '待接收', pending: '待接收',
accepted: '已接收', accepted: '已接收',
completed: '已完成', completed: '已完成',
} }
const statusColorMap: Record<string, 'warning' | 'primary' | 'success'> = { const statusStyleMap: Record<InstructionStatus, { type: 'warning' | 'primary' | 'success'; bg: string }> = {
pending: 'warning', pending: { type: 'warning', bg: 'var(--color-warning-bg)' },
accepted: 'primary', accepted: { type: 'primary', bg: 'var(--color-primary-bg)' },
completed: 'success', completed: { type: 'success', bg: 'var(--color-success-bg)' },
} }
/** 级别映射 */ const levelMap: Record<InstructionLevel, string> = {
const levelMap: Record<string, string> = {
urgent: '紧急', urgent: '紧急',
normal: '普通', normal: '普通',
} }
const levelColorMap: Record<string, string> = { const levelStyleMap: Record<InstructionLevel, { color: string; bg: string }> = {
urgent: '#ee0a24', urgent: { color: '#fff', bg: '#ee0a24' },
normal: '#1989fa', normal: { color: '#fff', bg: '#1989fa' },
} }
/** 模拟指令数据 */ // ── 数据 ──
const mockInstructions = [ const instructions = ref<Instruction[]>([])
const mockInstructions: Instruction[] = [
{ id: 1, title: '启动III级防汛应急响应', level: 'urgent', from: '市防汛指挥部', time: '2025-06-15 08:00', status: 'pending' }, { id: 1, title: '启动III级防汛应急响应', level: 'urgent', from: '市防汛指挥部', time: '2025-06-15 08:00', status: 'pending' },
{ id: 2, title: '河道巡查任务通知', level: 'normal', from: '水务局', time: '2025-06-14 14:30', status: 'accepted' }, { id: 2, title: '河道巡查任务通知', level: 'normal', from: '水务局', time: '2025-06-14 14:30', status: 'accepted' },
{ id: 3, title: '泵站设备检修通知', level: 'normal', from: '运维中心', time: '2025-06-13 09:00', status: 'completed' }, { id: 3, title: '泵站设备检修通知', level: 'normal', from: '运维中心', time: '2025-06-13 09:00', status: 'completed' },
{ id: 4, title: '重点积水路段值守', level: 'urgent', from: '市政管理处', time: '2025-06-15 07:30', status: 'pending' }, { id: 4, title: '重点积水路段值守', level: 'urgent', from: '市政管理处', time: '2025-06-15 07:30', status: 'pending' },
{ id: 5, title: '防洪物资调配通知', level: 'urgent', from: '市防汛指挥部', time: '2025-06-15 09:00', status: 'pending' },
{ id: 6, title: '排水管网排查任务', level: 'normal', from: '水务局', time: '2025-06-14 10:00', status: 'accepted' },
] ]
// ── 计算属性 ──
const filteredList = computed(() => { const filteredList = computed(() => {
let list = mockInstructions let list = instructions.value
if (searchText.value) { if (searchText.value) {
const kw = searchText.value.toLowerCase() const kw = searchText.value.toLowerCase()
list = list.filter(i => list = list.filter(i =>
@@ -54,54 +76,109 @@ const filteredList = computed(() => {
i.from.toLowerCase().includes(kw) i.from.toLowerCase().includes(kw)
) )
} }
if (activeTab.value === 1) list = list.filter(i => i.status === 'pending') const tabStatus: (InstructionStatus | null)[] = [null, 'pending', 'accepted', 'completed']
else if (activeTab.value === 2) list = list.filter(i => i.status === 'accepted') const filter = tabStatus[activeTab.value]
else if (activeTab.value === 3) list = list.filter(i => i.status === 'completed') if (filter) list = list.filter(i => i.status === filter)
return list return list
}) })
function goReceive(id: number) { const pendingCount = computed(() => instructions.value.filter(i => i.status === 'pending').length)
router.push('/instructionReceive')
// ── 方法 ──
async function fetchData() {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
instructions.value = mockInstructions
loading.value = false
} }
async function onRefresh() {
refreshing.value = true
await new Promise(resolve => setTimeout(resolve, 600))
instructions.value = [...mockInstructions]
refreshing.value = false
showToast('刷新成功')
}
function goReceive(item: Instruction) {
router.push({ path: '/instructionReceive', query: { id: item.id } })
}
onMounted(() => {
fetchData()
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- 导航栏 -->
<van-nav-bar title="防汛指令" left-arrow fixed placeholder @click-left="router.back()" /> <van-nav-bar title="防汛指令" left-arrow fixed placeholder @click-left="router.back()" />
<van-search v-model="searchText" placeholder="搜索指令标题、来源" shape="round" /> <!-- 搜索栏 -->
<van-search v-model="searchText" placeholder="搜索指令标题、来源单位" shape="round" />
<van-tabs v-model:active="activeTab" sticky> <!-- 标签页 -->
<van-tabs v-model:active="activeTab" sticky color="var(--color-primary)">
<van-tab title="全部" /> <van-tab title="全部" />
<van-tab title="待接收" /> <van-tab :title="`待接收${pendingCount ? ` (${pendingCount})` : ''}`" />
<van-tab title="已接收" /> <van-tab title="已接收" />
<van-tab title="已完成" /> <van-tab title="已完成" />
</van-tabs> </van-tabs>
<div class="card-list"> <!-- 下拉刷新 + 列表 -->
<van-empty v-if="filteredList.length === 0" description="暂无指令" /> <van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="pull-refresh-wrap">
<van-card <!-- 加载骨架 -->
v-for="item in filteredList" <div v-if="loading" class="skeleton-wrap">
:key="item.id" <div v-for="i in 4" :key="i" class="skeleton-card">
:title="item.title" <van-skeleton title :row="2" />
:desc="`来自: ${item.from}`" </div>
@click="goReceive(item.id)" </div>
>
<template #tags> <!-- 空状态 -->
<van-tag :color="levelColorMap[item.level]" size="medium" text-color="#fff"> <van-empty v-else-if="filteredList.length === 0" description="暂无指令">
{{ levelMap[item.level] }} <van-button type="primary" size="small" @click="onRefresh">刷新</van-button>
</van-tag> </van-empty>
<van-tag :type="statusColorMap[item.status]" size="medium">
{{ statusMap[item.status] }} <!-- 指令卡片列表 -->
</van-tag> <div v-else class="card-list">
</template> <div
<template #footer> v-for="item in filteredList"
<div class="card-meta"> :key="item.id"
<span>{{ item.time }}</span> class="inst-card"
@click="goReceive(item)"
>
<div class="card-hd">
<div class="card-title-wrap">
<span
class="level-badge"
:style="{ background: levelStyleMap[item.level].bg, color: levelStyleMap[item.level].color }"
>
{{ levelMap[item.level] }}
</span>
<span class="card-title">{{ item.title }}</span>
</div>
<span
class="status-badge"
:style="{ background: statusStyleMap[item.status].bg }"
>
<van-tag :type="statusStyleMap[item.status].type" size="medium">
{{ statusMap[item.status] }}
</van-tag>
</span>
</div> </div>
</template> <div class="card-bd">
</van-card> <div class="card-info">
</div> <van-icon name="user-o" size="14" />
<span>{{ item.from }}</span>
</div>
<div class="card-info">
<van-icon name="clock-o" size="14" />
<span>{{ item.time }}</span>
</div>
</div>
</div>
</div>
</van-pull-refresh>
</div> </div>
</template> </template>
@@ -118,22 +195,87 @@ function goReceive(id: number) {
} }
} }
.card-list { .pull-refresh-wrap {
padding: 0 8px; min-height: calc(100vh - 140px);
:deep(.van-card) {
margin: 8px;
border-radius: 10px;
background: var(--color-bg-card);
}
:deep(.van-tag) {
margin-right: 4px;
}
} }
.card-meta { // ── 骨架屏 ──
font-size: 12px; .skeleton-wrap {
color: var(--color-text-secondary); padding: 8px 16px;
}
.skeleton-card {
padding: 16px;
margin-bottom: 8px;
background: var(--color-bg-card);
border-radius: 10px;
}
// ── 指令卡片 ──
.card-list {
padding: 8px 12px;
}
.inst-card {
background: var(--color-bg-card);
border-radius: 10px;
padding: 16px;
margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
cursor: pointer;
&:active {
opacity: 0.85;
}
.card-hd {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
gap: 8px;
}
.card-title-wrap {
display: flex;
align-items: flex-start;
gap: 8px;
flex: 1;
min-width: 0;
}
.level-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.card-bd {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.card-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--color-text-secondary);
}
} }
</style> </style>

View File

@@ -1,53 +1,115 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 指令接收详情 * 指令接收详情 — 防汛指令接收与处理
* *
* 展示防汛指令的详细内容和操作按钮 * 展示防汛指令的完整内容,提供接收确认和退回操作
* 支持接收确认和退回操作 * 接收后可填写备注并确认完成
* DESIGN: #1E74FF NavBar, #F4F7F8 bg, white cards border-radius 10px.
*/ */
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { showSuccessToast, showConfirmDialog } from 'vant' import { showSuccessToast, showConfirmDialog, showToast } from 'vant'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
/** 接收状态 */ // ── 类型 ──
type InstructionLevel = 'urgent' | 'normal'
interface InstructionDetail {
id: number
title: string
level: InstructionLevel
from: string
time: string
deadline?: string
content: string
contact: string
phone: string
attachments?: string[]
}
// ── 状态 ──
const loading = ref(true)
const accepted = ref(false) const accepted = ref(false)
const remark = ref('') const remark = ref('')
const showDeclineDialog = ref(false)
const declineReason = ref('')
const actionLoading = ref(false)
/** 模拟指令详情 */ // ── 映射 ──
const instruction = { const levelMap: Record<InstructionLevel, { label: string; color: string }> = {
id: 1, urgent: { label: '紧急', color: '#ee0a24' },
normal: { label: '普通', color: '#1989fa' },
}
// ── 数据 ──
const instruction = ref<InstructionDetail>({
id: 0,
title: '',
level: 'normal',
from: '',
time: '',
content: '',
contact: '',
phone: '',
})
const mockDetail: InstructionDetail = {
id: Number(route.query.id) || 1,
title: '启动III级防汛应急响应', title: '启动III级防汛应急响应',
level: 'urgent', level: 'urgent',
from: '市防汛指挥部', from: '市防汛指挥部',
time: '2025-06-15 08:00', time: '2025-06-15 08:00',
content: '根据市气象台发布的暴雨橙色预警预计未来6小时内我市将出现大范围强降雨累计雨量可达80-120毫米。经研究决定自2025年6月15日08时起启动III级防汛应急响应。请各相关单位按要求落实防汛措施。', deadline: '2025-06-15 18:00',
content:
'根据市气象台发布的暴雨橙色预警预计未来6小时内我市将出现大范围强降雨累计雨量可达80-120毫米。经研究决定自2025年6月15日08时起启动III级防汛应急响应。\n\n请各相关单位按要求落实以下防汛措施\n1. 立即启动防汛应急预案,做好人员值班安排;\n2. 对重点区域进行巡查,排查安全隐患;\n3. 确保防汛物资储备充足,设备处于良好状态;\n4. 保持通讯畅通,及时上报汛情信息。',
contact: '张主任', contact: '张主任',
phone: '010-88886666', phone: '010-88886666',
attachments: ['防汛应急预案.pdf', '值班安排表.xlsx'],
} }
function onAccept() { // ── 方法 ──
async function onAccept() {
actionLoading.value = true
await new Promise(resolve => setTimeout(resolve, 500))
accepted.value = true accepted.value = true
actionLoading.value = false
showSuccessToast('已接收指令') showSuccessToast('已接收指令')
} }
function onDecline() { function onDecline() {
showConfirmDialog({ showDeclineDialog.value = true
title: '退回指令',
message: '确定要退回该指令吗?请填写退回原因。',
})
.then(() => {
showSuccessToast('已退回')
router.back()
})
.catch(() => {})
} }
async function confirmDecline() {
if (!declineReason.value.trim()) {
showToast('请填写退回原因')
return
}
await new Promise(resolve => setTimeout(resolve, 500))
showDeclineDialog.value = false
showSuccessToast('已退回')
router.back()
}
async function onComplete() {
actionLoading.value = true
await new Promise(resolve => setTimeout(resolve, 500))
actionLoading.value = false
showSuccessToast('处理完成')
router.back()
}
onMounted(async () => {
await new Promise(resolve => setTimeout(resolve, 300))
instruction.value = mockDetail
loading.value = false
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- 导航栏 -->
<van-nav-bar <van-nav-bar
title="指令详情" title="指令详情"
left-arrow left-arrow
@@ -56,60 +118,141 @@ function onDecline() {
@click-left="router.back()" @click-left="router.back()"
/> />
<van-cell-group inset> <!-- 加载状态 -->
<van-cell title="指令标题" :value="instruction.title" /> <van-loading v-if="loading" class="loading-center" size="24" />
<van-cell title="来源单位" :value="instruction.from" />
<van-cell title="发布时间" :value="instruction.time" />
<van-cell title="联 系 人" :value="instruction.contact" />
<van-cell title="联系电话" :value="instruction.phone" />
</van-cell-group>
<van-cell-group inset style="margin-top: 12px"> <template v-else>
<van-cell title="指令内容" /> <!-- 指令级别标签 -->
<div class="content-block">{{ instruction.content }}</div> <div class="level-header">
</van-cell-group> <span
class="level-tag"
:style="{ background: levelMap[instruction.level].color }"
>
{{ levelMap[instruction.level].label }}
</span>
<span class="level-label">防汛指令</span>
</div>
<van-cell-group v-if="accepted" inset style="margin-top: 12px"> <!-- 基本信息卡片 -->
<van-field <div class="info-card">
v-model="remark" <div class="card-title-row">{{ instruction.title }}</div>
label="备注" <div class="info-grid">
placeholder="可选填写接收备注" <div class="info-item">
type="textarea" <span class="info-label">来源单位</span>
rows="2" <span class="info-value">{{ instruction.from }}</span>
autosize </div>
/> <div class="info-item">
</van-cell-group> <span class="info-label">发布时间</span>
<span class="info-value">{{ instruction.time }}</span>
</div>
<div v-if="instruction.deadline" class="info-item">
<span class="info-label">截止时间</span>
<span class="info-value deadline">{{ instruction.deadline }}</span>
</div>
<div class="info-item">
<span class="info-label">联系人</span>
<span class="info-value">{{ instruction.contact }}</span>
</div>
<div class="info-item">
<span class="info-label">联系电话</span>
<span class="info-value">
<a :href="`tel:${instruction.phone}`">{{ instruction.phone }}</a>
</span>
</div>
</div>
</div>
<div class="action-bar"> <!-- 指令内容卡片 -->
<van-button <div class="content-card">
v-if="!accepted" <div class="section-label">指令内容</div>
type="primary" <div class="content-text">{{ instruction.content }}</div>
block </div>
round
@click="onAccept" <!-- 附件卡片 -->
> <div v-if="instruction.attachments?.length" class="info-card">
接收指令 <div class="section-label">附件</div>
</van-button> <div class="attach-list">
<van-button <div
v-else v-for="(file, i) in instruction.attachments"
type="success" :key="i"
block class="attach-item"
round >
@click="router.back()" <van-icon name="description" size="18" color="var(--color-primary)" />
> <span>{{ file }}</span>
确认完成 </div>
</van-button> </div>
<van-button </div>
v-if="!accepted"
type="default" <!-- 接收后备注 -->
block <div v-if="accepted" class="info-card">
round <van-field
style="margin-top: 10px" v-model="remark"
@click="onDecline" label="备注"
> placeholder="可选填写接收备注"
退回 type="textarea"
</van-button> rows="2"
</div> autosize
/>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<!-- 未接收状态 -->
<template v-if="!accepted">
<van-button
type="primary"
block
round
size="large"
:loading="actionLoading"
@click="onAccept"
>
接收指令
</van-button>
<van-button
type="default"
block
round
size="large"
class="btn-decline"
@click="onDecline"
>
退回
</van-button>
</template>
<!-- 已接收状态 -->
<template v-else>
<van-button
type="success"
block
round
size="large"
:loading="actionLoading"
@click="onComplete"
>
确认完成
</van-button>
</template>
</div>
</template>
<!-- 退回弹窗 -->
<van-dialog
v-model:show="showDeclineDialog"
title="退回指令"
show-cancel-button
@confirm="confirmDecline"
>
<div class="decline-body">
<van-field
v-model="declineReason"
type="textarea"
rows="3"
autosize
placeholder="请填写退回原因"
/>
</div>
</van-dialog>
</div> </div>
</template> </template>
@@ -117,7 +260,7 @@ function onDecline() {
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
background: var(--color-bg-page); background: var(--color-bg-page);
padding-bottom: 24px; padding-bottom: 150px;
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: var(--color-primary); background: var(--color-primary);
@@ -127,16 +270,144 @@ function onDecline() {
} }
} }
.content-block { .loading-center {
display: flex;
justify-content: center;
padding-top: 160px;
}
// ── 级别头部 ──
.level-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px; padding: 12px 16px;
.level-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
color: #fff;
}
.level-label {
font-size: 14px;
color: var(--color-text-secondary);
}
}
// ── 卡片通用 ──
.info-card,
.content-card {
margin: 0 12px 10px;
padding: 16px;
background: var(--color-bg-card);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.card-title-row {
font-size: 17px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.5;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border-light);
}
// ── 信息网格 ──
.info-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
.info-label {
width: 70px;
font-size: 13px;
color: var(--color-text-secondary);
flex-shrink: 0;
}
.info-value {
font-size: 14px;
color: var(--color-text-primary);
&.deadline {
color: var(--color-danger);
font-weight: 500;
}
a {
color: var(--color-primary);
}
}
}
// ── 段落标签 ──
.section-label {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 10px;
}
// ── 内容文本 ──
.content-text {
font-size: 14px; font-size: 14px;
line-height: 1.8; line-height: 1.8;
color: var(--color-text-regular); color: var(--color-text-regular);
background: var(--color-bg-card);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all;
} }
// ── 附件列表 ──
.attach-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attach-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-bg-page);
border-radius: 6px;
font-size: 13px;
color: var(--color-text-primary);
}
// ── 操作按钮区 ──
.action-bar { .action-bar {
padding: 24px 16px; position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
background: var(--color-bg-card);
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
gap: 10px;
}
.btn-decline {
--van-button-default-color: var(--color-danger) !important;
--van-button-default-border-color: var(--color-danger) !important;
}
// ── 退回弹窗 ──
.decline-body {
padding: 12px 16px 0;
} }
</style> </style>

View File

@@ -1,50 +1,103 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 物资出入库 * 物资出入库 — 防汛物资出入库操作
* *
* 对防汛物资进行入库或出库操作, * 对防汛物资进行入库或出库操作,记录数量、批次号、
* 记录数量、类型和操作说明 * 操作人和备注。支持表单验证和操作确认
* DESIGN: #1E74FF NavBar, #F4F7F8 bg, white cards border-radius 10px.
*/ */
import { ref } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { showSuccessToast } from 'vant' import { showSuccessToast, showToast } from 'vant'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
/** 操作类型 */ // ── 类型 ──
const opType = ref<'in' | 'out'>('in') type OpType = 'in' | 'out'
interface MaterialInfo {
id: string | string[]
name: string
stock: number
unit: string
location: string
type: string
}
// ── 状态 ──
const opType = ref<OpType>('in')
const quantity = ref('') const quantity = ref('')
const batchNo = ref('') const batchNo = ref('')
const operator = ref('') const operator = ref('')
const remark = ref('') const remark = ref('')
const submitting = ref(false)
const loading = ref(true)
/** 模拟物资信息 */ // ── 数据 ──
const material = { const material = ref<MaterialInfo>({
id: route.params.id, id: route.params.id || '1',
name: '柴油水泵', name: '柴油水泵',
stock: 12, stock: 12,
unit: '台', unit: '台',
location: '1号仓库', location: '1号仓库',
type: '水泵类',
})
// ── 计算属性 ──
const submitLabel = computed(() => opType.value === 'in' ? '确认入库' : '确认出库')
const opLabel = computed(() => opType.value === 'in' ? '入库' : '出库')
/** 出库时检查是否超量 */
const isOverStock = computed(() => {
if (opType.value === 'out' && quantity.value) {
return Number(quantity.value) > material.value.stock
}
return false
})
// ── 方法 ──
function onRadioChange(val: OpType) {
opType.value = val
quantity.value = '' // 切换类型时清空数量
} }
function onSubmit() { function onSubmit() {
if (!quantity.value) { if (!quantity.value) {
showSuccessToast('请填写数量') showToast('请填写数量')
return return
} }
if (!operator.value) { const num = Number(quantity.value)
showSuccessToast('请填写操作人') if (num <= 0 || !Number.isInteger(num)) {
showToast('请输入正整数')
return return
} }
const action = opType.value === 'in' ? '入库' : '出库' if (opType.value === 'out' && num > material.value.stock) {
showSuccessToast(`${action}成功`) showToast('出库数量不能超过当前库存')
router.back() return
}
if (!operator.value.trim()) {
showToast('请填写操作人')
return
}
submitting.value = true
setTimeout(() => {
submitting.value = false
showSuccessToast(`${opLabel.value}成功`)
router.back()
}, 600)
} }
onMounted(async () => {
await new Promise(resolve => setTimeout(resolve, 300))
loading.value = false
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- 导航栏 -->
<van-nav-bar <van-nav-bar
title="出入库" title="出入库"
left-arrow left-arrow
@@ -53,67 +106,132 @@ function onSubmit() {
@click-left="router.back()" @click-left="router.back()"
/> />
<van-cell-group inset> <!-- 加载状态 -->
<van-cell title="物资名称" :value="material.name" /> <van-loading v-if="loading" class="loading-center" size="24" />
<van-cell title="当前库存" :value="`${material.stock} ${material.unit}`" />
<van-cell title="存放位置" :value="material.location" />
</van-cell-group>
<van-form @submit="onSubmit" style="margin-top: 12px"> <template v-else>
<van-cell-group inset> <!-- 物资信息卡片 -->
<van-field <div class="info-card">
name="opType" <div class="card-title-row">{{ material.name }}</div>
label="操作类型" <div class="info-grid">
> <div class="info-item">
<template #input> <span class="info-label">当前库存</span>
<van-radio-group v-model="opType" direction="horizontal"> <span class="info-value stock-val">{{ material.stock }} {{ material.unit }}</span>
<van-radio name="in">入库</van-radio> </div>
<van-radio name="out">出库</van-radio> <div class="info-item">
</van-radio-group> <span class="info-label">存放位置</span>
</template> <span class="info-value">{{ material.location }}</span>
</van-field> </div>
<van-field </div>
v-model="quantity"
name="quantity"
label="数量"
:placeholder="`请输入${opType === 'in' ? '入库' : '出库'}数量`"
type="digit"
:rules="[{ required: true, message: '请填写数量' }]"
>
<template #extra>
<span class="unit-text">{{ material.unit }}</span>
</template>
</van-field>
<van-field
v-model="batchNo"
name="batchNo"
label="批次号"
placeholder="可选填写批次号"
/>
<van-field
v-model="operator"
name="operator"
label="操作人"
placeholder="请输入操作人姓名"
:rules="[{ required: true, message: '请填写操作人' }]"
/>
<van-field
v-model="remark"
name="remark"
label="备注"
placeholder="可选填写备注说明"
type="textarea"
rows="2"
autosize
/>
</van-cell-group>
<div class="submit-wrap">
<van-button type="primary" block round native-type="submit">
确认{{ opType === 'in' ? '入库' : '出库' }}
</van-button>
</div> </div>
</van-form>
<!-- 操作表单 -->
<van-form @submit="onSubmit" class="form-wrap">
<!-- 操作类型 (Radio ) -->
<div class="form-card">
<div class="form-label">操作类型</div>
<van-radio-group
:model-value="opType"
direction="horizontal"
class="radio-group"
>
<van-radio
name="in"
icon-size="18"
checked-color="var(--color-success)"
@click="onRadioChange('in')"
>
<span class="radio-label" :class="{ active: opType === 'in' }">入库</span>
</van-radio>
<van-radio
name="out"
icon-size="18"
checked-color="var(--color-danger)"
@click="onRadioChange('out')"
>
<span class="radio-label" :class="{ active: opType === 'out' }">出库</span>
</van-radio>
</van-radio-group>
</div>
<!-- 表单字段 -->
<div class="form-card">
<van-field
v-model="quantity"
name="quantity"
:label="`${opLabel}数量`"
:placeholder="`请输入${opLabel}数量`"
type="digit"
clearable
:rules="[{ required: true, message: `请填写${opLabel}数量` }]"
:error="isOverStock"
:error-message="isOverStock ? '出库数量不能超过当前库存' : ''"
>
<template #extra>
<span class="unit-text">{{ material.unit }}</span>
</template>
</van-field>
<van-field
v-model="batchNo"
name="batchNo"
label="批次号"
placeholder="可选填写批次号"
clearable
/>
<van-field
v-model="operator"
name="operator"
label="操作人"
placeholder="请输入操作人姓名"
clearable
:rules="[{ required: true, message: '请填写操作人' }]"
/>
<van-field
v-model="remark"
name="remark"
label="备注"
placeholder="可选填写备注说明"
type="textarea"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
</div>
<!-- 操作预览提示 -->
<div class="preview-hint" :class="opType">
<van-icon
:name="opType === 'in' ? 'arrow-up' : 'arrow-down'"
size="16"
/>
<span>
{{ opType === 'in' ? '入库' : '出库' }}
<template v-if="quantity">
<strong>{{ quantity }}</strong> {{ material.unit }}
</template>
{{ material.name }}
</span>
</div>
<!-- 提交按钮 -->
<div class="submit-wrap">
<van-button
:type="opType === 'in' ? 'primary' : 'danger'"
block
round
size="large"
native-type="submit"
:loading="submitting"
>
{{ submitLabel }}
</van-button>
</div>
</van-form>
</template>
</div> </div>
</template> </template>
@@ -121,6 +239,7 @@ function onSubmit() {
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
background: var(--color-bg-page); background: var(--color-bg-page);
padding-bottom: 40px;
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: var(--color-primary); background: var(--color-primary);
@@ -130,12 +249,148 @@ function onSubmit() {
} }
} }
.loading-center {
display: flex;
justify-content: center;
padding-top: 160px;
}
// ── 物资信息卡片 ──
.info-card {
margin: 0 12px 10px;
padding: 16px;
background: var(--color-bg-card);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.card-title-row {
font-size: 17px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border-light);
}
.info-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-item {
display: flex;
align-items: center;
.info-label {
width: 70px;
font-size: 13px;
color: var(--color-text-secondary);
flex-shrink: 0;
}
.info-value {
font-size: 14px;
color: var(--color-text-primary);
&.stock-val {
font-size: 18px;
font-weight: 700;
color: var(--color-primary);
}
}
}
// ── 表单 ──
.form-wrap {
padding: 0 12px;
}
.form-card {
margin-bottom: 10px;
padding: 12px 0 0;
background: var(--color-bg-card);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
:deep(.van-cell) {
padding: 12px 16px;
}
:deep(.van-cell::after) {
left: 16px;
right: 16px;
}
}
.form-label {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
padding: 0 16px 4px;
}
// ── 操作类型 Radio ──
.radio-group {
display: flex;
gap: 32px;
padding: 8px 16px 12px;
:deep(.van-radio) {
margin-right: 0;
}
}
.radio-label {
font-size: 15px;
font-weight: 500;
color: var(--color-text-secondary);
transition: color 0.2s;
&.active {
color: var(--color-text-primary);
font-weight: 600;
}
}
.unit-text { .unit-text {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 14px; font-size: 14px;
} }
// ── 预览提示 ──
.preview-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
margin-bottom: 12px;
font-size: 13px;
border-radius: 8px;
&.in {
background: var(--color-success-bg);
color: var(--color-success-dark);
}
&.out {
background: var(--color-danger-bg);
color: var(--color-danger-dark);
}
strong {
font-weight: 600;
}
&:empty {
display: none;
}
}
// ── 提交按钮 ──
.submit-wrap { .submit-wrap {
padding: 24px 16px; padding: 8px 4px 40px;
} }
</style> </style>

View File

@@ -1,56 +1,80 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 防汛物资管理 * 防汛物资管理 — 物资库存列表
* *
* 展示防汛物资库存列表,支持按类型筛选搜索, * 展示防汛物资库存情况,支持按类型筛选搜索和下拉刷新
* 显示各物资的库存数量 * 每条物资显示库存数量和库存状态
* DESIGN: #1E74FF NavBar, #F4F7F8 bg, white cards border-radius 10px.
*/ */
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from 'vant'
const router = useRouter() const router = useRouter()
// ── 类型 ──
type MaterialType = 'pump' | 'sandbag' | 'tool' | 'rescue'
type StockStatus = 'sufficient' | 'normal' | 'low' | 'shortage'
interface Material {
id: number
name: string
type: MaterialType
stock: number
unit: string
minStock: number
status: StockStatus
location: string
}
// ── 状态 ──
const searchText = ref('') const searchText = ref('')
const activeTab = ref(0) const activeTab = ref(0)
const loading = ref(false)
const refreshing = ref(false)
/** 物资类型映射 */ // ── 映射 ──
const typeMap: Record<string, string> = { const typeMap: Record<MaterialType, string> = {
pump: '水泵类', pump: '水泵类',
sandbag: '沙袋类', sandbag: '沙袋类',
tool: '工具类', tool: '工具类',
rescue: '救援类', rescue: '救援类',
} }
const typeColorMap: Record<string, string> = { const typeColorMap: Record<MaterialType, string> = {
pump: '#1989fa', pump: '#1989fa',
sandbag: '#ff976a', sandbag: '#ff976a',
tool: '#07c160', tool: '#07c160',
rescue: '#ee0a24', rescue: '#ee0a24',
} }
/** 库存状态 */ const stockStatusMap: Record<StockStatus, { label: string; type: string; icon: string }> = {
const stockStatusMap: Record<string, { label: string; type: 'success' | 'warning' | 'danger' }> = { sufficient: { label: '充足', type: 'success', icon: 'passed' },
sufficient: { label: '充足', type: 'success' }, normal: { label: '正常', type: 'primary', icon: 'info-o' },
normal: { label: '正常', type: '' as 'success' }, low: { label: '偏低', type: 'warning', icon: 'warning-o' },
low: { label: '偏低', type: 'warning' }, shortage: { label: '紧缺', type: 'danger', icon: 'close' },
shortage: { label: '紧缺', type: 'danger' },
} }
/** 模拟物资数据 */ const typeTabs = ['all', 'pump', 'sandbag', 'tool', 'rescue']
const mockMaterials = [ const typeTabNames = ['全部', '水泵类', '沙袋类', '工具类', '救援类']
// ── 数据 ──
const materials = ref<Material[]>([])
const mockMaterials: Material[] = [
{ id: 1, name: '柴油水泵', type: 'pump', stock: 12, unit: '台', minStock: 5, status: 'sufficient', location: '1号仓库' }, { id: 1, name: '柴油水泵', type: 'pump', stock: 12, unit: '台', minStock: 5, status: 'sufficient', location: '1号仓库' },
{ id: 2, name: '防洪沙袋', type: 'sandbag', stock: 500, unit: '个', minStock: 200, status: 'sufficient', location: '2号仓库' }, { id: 2, name: '防洪沙袋', type: 'sandbag', stock: 500, unit: '个', minStock: 200, status: 'sufficient', location: '2号仓库' },
{ id: 3, name: '救生衣', type: 'rescue', stock: 30, unit: '件', minStock: 50, status: 'low', location: '1号仓库' }, { id: 3, name: '救生衣', type: 'rescue', stock: 30, unit: '件', minStock: 50, status: 'low', location: '1号仓库' },
{ id: 4, name: '铁锹', type: 'tool', stock: 8, unit: '把', minStock: 20, status: 'shortage', location: '2号仓库' }, { id: 4, name: '铁锹', type: 'tool', stock: 8, unit: '把', minStock: 20, status: 'shortage', location: '2号仓库' },
{ id: 5, name: '发电机', type: 'pump', stock: 6, unit: '台', minStock: 3, status: 'normal', location: '1号仓库' }, { id: 5, name: '发电机', type: 'pump', stock: 6, unit: '台', minStock: 3, status: 'normal', location: '1号仓库' },
{ id: 6, name: '应急照明灯', type: 'tool', stock: 15, unit: '个', minStock: 10, status: 'normal', location: '3号仓库' }, { id: 6, name: '应急照明灯', type: 'tool', stock: 15, unit: '个', minStock: 10, status: 'normal', location: '3号仓库' },
{ id: 7, name: '救生圈', type: 'rescue', stock: 45, unit: '个', minStock: 30, status: 'sufficient', location: '3号仓库' },
{ id: 8, name: '编织袋', type: 'sandbag', stock: 80, unit: '条', minStock: 300, status: 'shortage', location: '2号仓库' },
] ]
const typeTabs = ['all', 'pump', 'sandbag', 'tool', 'rescue'] // ── 计算属性 ──
const typeTabNames = ['全部', '水泵类', '沙袋类', '工具类', '救援类']
const filteredList = computed(() => { const filteredList = computed(() => {
let list = mockMaterials let list = materials.value
if (searchText.value) { if (searchText.value) {
const kw = searchText.value.toLowerCase() const kw = searchText.value.toLowerCase()
list = list.filter(m => list = list.filter(m =>
@@ -64,46 +88,122 @@ const filteredList = computed(() => {
return list return list
}) })
function goInventory(id: number) { // ── 方法 ──
router.push(`/managementInventory/${id}`) async function fetchData() {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
materials.value = mockMaterials
loading.value = false
} }
async function onRefresh() {
refreshing.value = true
await new Promise(resolve => setTimeout(resolve, 600))
materials.value = [...mockMaterials]
refreshing.value = false
showToast('刷新成功')
}
function goInventory(item: Material) {
router.push(`/managementInventory/${item.id}`)
}
/** 库存进度百分比 */
function stockPercent(item: Material): number {
const max = Math.max(item.minStock * 2, item.stock)
return Math.min((item.stock / max) * 100, 100)
}
onMounted(() => {
fetchData()
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- 导航栏 -->
<van-nav-bar title="物资管理" left-arrow fixed placeholder @click-left="router.back()" /> <van-nav-bar title="物资管理" left-arrow fixed placeholder @click-left="router.back()" />
<!-- 搜索栏 -->
<van-search v-model="searchText" placeholder="搜索物资名称、存放位置" shape="round" /> <van-search v-model="searchText" placeholder="搜索物资名称、存放位置" shape="round" />
<van-tabs v-model:active="activeTab" sticky> <!-- 标签页 -->
<van-tabs v-model:active="activeTab" sticky color="var(--color-primary)">
<van-tab v-for="(name, i) in typeTabNames" :key="i" :title="name" /> <van-tab v-for="(name, i) in typeTabNames" :key="i" :title="name" />
</van-tabs> </van-tabs>
<div class="card-list"> <!-- 下拉刷新 + 列表 -->
<van-empty v-if="filteredList.length === 0" description="暂无物资" /> <van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="pull-refresh-wrap">
<van-card <!-- 加载骨架 -->
v-for="item in filteredList" <div v-if="loading" class="skeleton-wrap">
:key="item.id" <div v-for="i in 4" :key="i" class="skeleton-card">
:title="item.name" <van-skeleton title :row="2" />
:desc="`存放位置: ${item.location}`" </div>
@click="goInventory(item.id)" </div>
>
<template #tags> <!-- 空状态 -->
<van-tag :color="typeColorMap[item.type]" size="medium" text-color="#fff"> <van-empty v-else-if="filteredList.length === 0" description="暂无物资">
{{ typeMap[item.type] }} <van-button type="primary" size="small" @click="onRefresh">刷新</van-button>
</van-tag> </van-empty>
<van-tag :type="stockStatusMap[item.status].type" size="medium">
{{ stockStatusMap[item.status].label }} <!-- 物资卡片列表 -->
</van-tag> <div v-else class="card-list">
</template> <div
<template #footer> v-for="item in filteredList"
<div class="card-meta"> :key="item.id"
<span class="stock-count">库存: {{ item.stock }} {{ item.unit }}</span> class="mat-card"
<span class="stock-min">最低库存: {{ item.minStock }} {{ item.unit }}</span> @click="goInventory(item)"
>
<div class="card-hd">
<div class="card-title-wrap">
<span
class="type-badge"
:style="{ background: typeColorMap[item.type] }"
>
{{ typeMap[item.type] }}
</span>
<span class="card-name">{{ item.name }}</span>
</div>
<van-tag :type="stockStatusMap[item.status].type as any" size="medium">
{{ stockStatusMap[item.status].label }}
</van-tag>
</div> </div>
</template>
</van-card> <div class="card-bd">
</div> <div class="stock-info">
<div class="stock-row">
<span class="stock-label">库存数量</span>
<span class="stock-value" :class="{ 'stock-low': item.status === 'low' || item.status === 'shortage' }">
{{ item.stock }} <small>{{ item.unit }}</small>
</span>
</div>
<div class="stock-row">
<span class="stock-label">最低库存</span>
<span class="stock-min">{{ item.minStock }} {{ item.unit }}</span>
</div>
</div>
<!-- 库存进度条 -->
<div class="stock-progress">
<div
class="progress-fill"
:style="{
width: stockPercent(item) + '%',
background: typeColorMap[item.type],
}"
/>
</div>
</div>
<div class="card-ft">
<span class="card-location">
<van-icon name="location-o" size="13" />
{{ item.location }}
</span>
</div>
</div>
</div>
</van-pull-refresh>
</div> </div>
</template> </template>
@@ -120,32 +220,141 @@ function goInventory(id: number) {
} }
} }
.card-list { .pull-refresh-wrap {
padding: 0 8px; min-height: calc(100vh - 140px);
:deep(.van-card) {
margin: 8px;
border-radius: 10px;
background: var(--color-bg-card);
}
:deep(.van-tag) {
margin-right: 4px;
}
} }
.card-meta { // ── 骨架屏 ──
display: flex; .skeleton-wrap {
gap: 12px; padding: 8px 16px;
font-size: 12px; }
color: var(--color-text-secondary); .skeleton-card {
padding: 16px;
margin-bottom: 8px;
background: var(--color-bg-card);
border-radius: 10px;
}
.stock-count { // ── 物资卡片 ──
font-weight: 500; .card-list {
padding: 8px 12px;
}
.mat-card {
background: var(--color-bg-card);
border-radius: 10px;
padding: 16px;
margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
cursor: pointer;
&:active {
opacity: 0.85;
}
.card-hd {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 14px;
gap: 8px;
}
.card-title-wrap {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.card-name {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-bd {
margin-bottom: 10px;
}
// ── 库存信息 ──
.stock-info {
margin-bottom: 8px;
}
.stock-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.stock-label {
font-size: 13px;
color: var(--color-text-secondary);
}
.stock-value {
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
&.stock-low {
color: var(--color-danger);
}
small {
font-size: 12px;
font-weight: 400;
color: var(--color-text-secondary);
}
} }
.stock-min { .stock-min {
font-size: 12px;
color: var(--color-text-placeholder);
}
// ── 进度条 ──
.stock-progress {
height: 6px;
background: var(--color-border-light);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.6s ease;
}
// ── 底部 ──
.card-ft {
padding-top: 8px;
border-top: 1px solid var(--color-border-light);
}
.card-location {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--color-text-placeholder); color: var(--color-text-placeholder);
} }
} }

View File

@@ -1,41 +1,84 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 选择班组成员 * 选择班组成员 — 防汛任务人员分配
* *
* 展示班组成员列表,支持搜索和多选, * 展示班组成员列表,支持搜索和多选勾选
* 用于分配防汛任务时的成员勾选。 * 按班组分组展示,用于分配防汛任务时选定人员
* DESIGN: #1E74FF NavBar, #F4F7F8 bg, white cards border-radius 10px.
*/ */
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant' import { showToast, showSuccessToast } from 'vant'
const router = useRouter() const router = useRouter()
const route = useRoute()
// ── 类型 ──
interface Member {
id: number
name: string
phone: string
team: string
role: '组长' | '组员'
}
// ── 状态 ──
const searchText = ref('') const searchText = ref('')
const checkedIds = ref<number[]>([]) const checkedIds = ref<number[]>([])
const loading = ref(false)
/** 模拟成员数据 */ // ── 模拟数据 ──
const mockMembers = [ const members = ref<Member[]>([])
const mockMembers: Member[] = [
{ id: 1, name: '张建国', phone: '13800001001', team: 'A组', role: '组长' }, { id: 1, name: '张建国', phone: '13800001001', team: 'A组', role: '组长' },
{ id: 2, name: '李明辉', phone: '13800001002', team: 'A组', role: '组员' }, { id: 2, name: '李明辉', phone: '13800001002', team: 'A组', role: '组员' },
{ id: 3, name: '陈志远', phone: '13800001003', team: 'A组', role: '组员' }, { id: 3, name: '陈志远', phone: '13800001003', team: 'A组', role: '组员' },
{ id: 4, name: '王强', phone: '13800001004', team: 'B组', role: '组长' }, { id: 4, name: '王强', phone: '13800001004', team: 'B组', role: '组长' },
{ id: 5, name: '赵勇', phone: '13800001005', team: 'B组', role: '组员' }, { id: 5, name: '赵勇', phone: '13800001005', team: 'B组', role: '组员' },
{ id: 6, name: '周文博', phone: '13800001006', team: 'B组', role: '组员' }, { id: 6, name: '周文博', phone: '13800001006', team: 'B组', role: '组员' },
{ id: 7, name: '刘大伟', phone: '13800001007', team: 'C组', role: '组长' },
{ id: 8, name: '孙志强', phone: '13800001008', team: 'C组', role: '组员' },
{ id: 9, name: '马小军', phone: '13800001009', team: 'C组', role: '组员' },
] ]
// ── 计算属性 ──
const filteredList = computed(() => { const filteredList = computed(() => {
if (!searchText.value) return mockMembers if (!searchText.value) return members.value
const kw = searchText.value.toLowerCase() const kw = searchText.value.toLowerCase()
return mockMembers.filter(m => return members.value.filter(m =>
m.name.toLowerCase().includes(kw) || m.name.toLowerCase().includes(kw) ||
m.phone.includes(kw) m.phone.includes(kw) ||
m.team.toLowerCase().includes(kw)
) )
}) })
/** 按班组分组 */
const groupedMembers = computed(() => {
const groups: Record<string, Member[]> = {}
for (const m of filteredList.value) {
if (!groups[m.team]) groups[m.team] = []
groups[m.team].push(m)
}
return groups
})
const isAllChecked = computed(() =>
filteredList.value.length > 0 &&
filteredList.value.every(m => checkedIds.value.includes(m.id))
)
// ── 方法 ──
function toggleCheck(id: number) {
const idx = checkedIds.value.indexOf(id)
if (idx >= 0) {
checkedIds.value.splice(idx, 1)
} else {
checkedIds.value.push(id)
}
}
function toggleAll() { function toggleAll() {
if (checkedIds.value.length === filteredList.value.length) { if (isAllChecked.value) {
checkedIds.value = [] checkedIds.value = []
} else { } else {
checkedIds.value = filteredList.value.map(m => m.id) checkedIds.value = filteredList.value.map(m => m.id)
@@ -43,53 +86,87 @@ function toggleAll() {
} }
function onConfirm() { function onConfirm() {
if (checkedIds.value.length === 0) {
showToast('请至少选择一名成员')
return
}
showSuccessToast(`已选择 ${checkedIds.value.length}`) showSuccessToast(`已选择 ${checkedIds.value.length}`)
router.back() router.back()
} }
onMounted(async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 300))
members.value = mockMembers
loading.value = false
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- 导航栏 -->
<van-nav-bar title="选择班组成员" left-arrow fixed placeholder @click-left="router.back()"> <van-nav-bar title="选择班组成员" left-arrow fixed placeholder @click-left="router.back()">
<template #right> <template #right>
<span class="nav-action" @click="toggleAll"> <span class="nav-action" @click="toggleAll">
{{ checkedIds.length === filteredList.length && filteredList.length > 0 ? '取消全选' : '全选' }} {{ isAllChecked ? '取消全选' : '全选' }}
</span> </span>
</template> </template>
</van-nav-bar> </van-nav-bar>
<van-search v-model="searchText" placeholder="搜索姓名、手机号" shape="round" /> <!-- 搜索栏 -->
<van-search v-model="searchText" placeholder="搜索姓名、手机号、班组" shape="round" />
<van-checkbox-group v-model="checkedIds"> <!-- 已选提示 -->
<van-cell-group inset> <div v-if="checkedIds.length > 0" class="selected-bar">
<van-cell <span>已选 <strong>{{ checkedIds.length }}</strong> </span>
v-for="item in filteredList" <span class="selected-clear" @click="checkedIds = []">清空</span>
:key="item.id" </div>
:title="item.name"
:label="`${item.team} · ${item.role}`" <!-- 加载状态 -->
clickable <van-loading v-if="loading" class="loading-center" size="24" />
@click="() => {
const idx = checkedIds.indexOf(item.id) <!-- 空状态 -->
if (idx >= 0) checkedIds.splice(idx, 1) <van-empty v-else-if="filteredList.length === 0" description="暂未找到成员" />
else checkedIds.push(item.id)
}" <!-- 成员列表 (按班组分组) -->
> <van-checkbox-group v-else v-model="checkedIds" class="member-list">
<template #value> <template v-for="(group, team) in groupedMembers" :key="team">
<van-tag :type="item.role === '组长' ? 'primary' : undefined" size="medium"> <div class="group-header">{{ team }}</div>
{{ item.role }} <van-cell-group inset>
</van-tag> <van-cell
</template> v-for="item in group"
<template #right-icon> :key="item.id"
<van-checkbox :name="item.id" /> :title="item.name"
</template> :label="item.phone"
</van-cell> clickable
</van-cell-group> @click="toggleCheck(item.id)"
>
<template #value>
<van-tag :type="item.role === '组长' ? 'primary' : undefined" size="medium">
{{ item.role }}
</van-tag>
</template>
<template #right-icon>
<van-checkbox
:name="item.id"
@click.stop
:icon-size="20"
/>
</template>
</van-cell>
</van-cell-group>
</template>
</van-checkbox-group> </van-checkbox-group>
<van-empty v-if="filteredList.length === 0" description="暂未找到成员" /> <!-- 底部确认栏 -->
<div class="footer-bar"> <div class="footer-bar">
<van-button type="primary" block round @click="onConfirm"> <van-button
type="primary"
block
round
:disabled="checkedIds.length === 0"
@click="onConfirm"
>
确认选择 ({{ checkedIds.length }}) 确认选择 ({{ checkedIds.length }})
</van-button> </van-button>
</div> </div>
@@ -116,6 +193,56 @@ function onConfirm() {
cursor: pointer; cursor: pointer;
} }
// ── 已选提示栏 ──
.selected-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
font-size: 13px;
color: var(--color-text-secondary);
background: var(--color-primary-bg);
strong {
color: var(--color-primary);
}
.selected-clear {
color: var(--color-danger);
cursor: pointer;
}
}
// ── 加载 ──
.loading-center {
display: flex;
justify-content: center;
padding-top: 120px;
}
// ── 分组 ──
.group-header {
padding: 12px 16px 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.member-list {
padding: 0 4px;
}
:deep(.van-cell-group) {
margin: 4px 8px;
border-radius: 10px;
overflow: hidden;
}
:deep(.van-cell) {
padding: 14px 16px;
}
// ── 底部栏 ──
.footer-bar { .footer-bar {
position: fixed; position: fixed;
bottom: 0; bottom: 0;

View File

@@ -6,7 +6,7 @@
*/ */
import { ref } from 'vue' import { ref } from 'vue'
const props = defineProps<{ defineProps<{
show: boolean show: boolean
}>() }>()
@@ -14,17 +14,23 @@ const emit = defineEmits<{
'update:show': [val: boolean] 'update:show': [val: boolean]
}>() }>()
/** 模拟台账数据 */ /** 台账数据 */
const records = ref([ const records = ref([
{ id: 1, name: 'DN300 雨水管-城北段', type: '雨水管网', length: '2.3km' }, { id: 1, name: 'DN300 雨水管-城北段', type: '雨水管网', length: '2.3km' },
{ id: 2, name: 'DN400 污水管-高新区', type: '污水管网', length: '1.8km' }, { id: 2, name: 'DN400 污水管-高新区', type: '污水管网', length: '1.8km' },
{ id: 3, name: '1# 提升泵站', type: '泵站', length: '-' }, { id: 3, name: '1# 提升泵站', type: '泵站', length: '-' },
{ id: 4, name: '中山河节制闸', type: '水闸', length: '-' }, { id: 4, name: '中山河节制闸', type: '水闸', length: '-' },
{ id: 5, name: 'DN200 给水管-老城区', type: '给水管网', length: '3.1km' },
]) ])
function onClose() { function onClose() {
emit('update:show', false) emit('update:show', false)
} }
function onItemClick(item: typeof records.value[number]) {
// 处理台账项点击
console.log('selected:', item)
}
</script> </script>
<template> <template>
@@ -35,85 +41,125 @@ function onClose() {
:style="{ height: '50%' }" :style="{ height: '50%' }"
@update:show="emit('update:show', $event)" @update:show="emit('update:show', $event)"
> >
<div class="tckz-pop"> <div class="pop-container">
<!-- Header -->
<div class="pop-header"> <div class="pop-header">
<span class="pop-title">台账信息</span> <span class="pop-title">台账信息</span>
<van-icon name="cross" size="20" color="#999" @click="onClose" /> <span class="pop-count">{{ records.length }} 条记录</span>
<van-icon name="cross" size="20" color="#C8C9CC" @click="onClose" />
</div> </div>
<!-- Body -->
<div class="pop-body"> <div class="pop-body">
<div <div
v-for="item in records" v-for="item in records"
:key="item.id" :key="item.id"
class="record-item" class="record-item"
@click="onItemClick(item)"
> >
<div class="record-icon"> <div class="record-icon-wrap">
<van-icon name="notes-o" size="20" color="#1989fa" /> <van-icon name="notes-o" size="22" color="#1E74FF" />
</div> </div>
<div class="record-info"> <div class="record-info">
<span class="record-name">{{ item.name }}</span> <span class="record-name">{{ item.name }}</span>
<span class="record-meta">{{ item.type }} · {{ item.length }}</span> <span class="record-meta">{{ item.type }} · {{ item.length }}</span>
</div> </div>
<van-icon name="arrow" color="#ccc" /> <van-icon name="arrow" color="#C8C9CC" size="16" />
</div> </div>
</div> </div>
<!-- Safe Area -->
<div class="pop-safe-bottom" />
</div> </div>
</van-popup> </van-popup>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.tckz-pop { .pop-container {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #fff;
}
.pop-header { // ── Header ──
display: flex; .pop-header {
justify-content: space-between; display: flex;
align-items: center; align-items: center;
padding: 16px 20px; gap: 8px;
border-bottom: 1px solid var(--color-border); padding: 18px 20px;
border-bottom: 1px solid #EBEDF0;
flex-shrink: 0;
.pop-title { .pop-title {
font-size: 16px; font-size: 17px;
font-weight: 600; font-weight: 600;
color: var(--color-text-regular); color: #323233;
}
}
.pop-body {
flex: 1; flex: 1;
overflow-y: auto;
padding: 8px 0;
} }
.record-item { .pop-count {
font-size: 12px;
color: #969799;
}
}
// ── Body ──
.pop-body {
flex: 1;
overflow-y: auto;
}
.record-item {
display: flex;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #F2F3F5;
cursor: pointer;
transition: background 0.15s;
&:active {
background: #F7F8FA;
}
.record-icon-wrap {
width: 36px;
height: 36px;
border-radius: 8px;
background: #E3F2FD;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 20px; justify-content: center;
border-bottom: 1px solid var(--color-border); margin-right: 12px;
cursor: pointer; flex-shrink: 0;
}
.record-icon { .record-info {
margin-right: 12px; flex: 1;
flex-shrink: 0; display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
.record-name {
font-size: 15px;
color: #323233;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.record-info { .record-meta {
flex: 1; font-size: 12px;
display: flex; color: #969799;
flex-direction: column;
gap: 4px;
.record-name {
font-size: 14px;
color: var(--color-text-regular);
}
.record-meta {
font-size: 12px;
color: var(--color-text-placeholder);
}
} }
} }
} }
// ── Safe Area ──
.pop-safe-bottom {
height: env(safe-area-inset-bottom, 16px);
flex-shrink: 0;
}
</style> </style>

View File

@@ -14,7 +14,7 @@ const emit = defineEmits<{
'update:show': [val: boolean] 'update:show': [val: boolean]
}>() }>()
/** 模拟仪表数据 */ /** 仪表数据 */
const gauges = ref([ const gauges = ref([
{ id: 1, name: '流量', value: '12.5', unit: 'm³/h', status: 'normal' }, { id: 1, name: '流量', value: '12.5', unit: 'm³/h', status: 'normal' },
{ id: 2, name: '压力', value: '0.35', unit: 'MPa', status: 'normal' }, { id: 2, name: '压力', value: '0.35', unit: 'MPa', status: 'normal' },
@@ -22,9 +22,13 @@ const gauges = ref([
{ id: 4, name: '水质', value: '6.8', unit: 'pH', status: 'normal' }, { id: 4, name: '水质', value: '6.8', unit: 'pH', status: 'normal' },
]) ])
const statusColorMap: Record<string, string> = { const statusCfg: Record<string, { color: string; bg: string; label: string }> = {
normal: '#07c160', normal: { color: '#07C160', bg: '#E8F8EF', label: '正常' },
alarm: '#ee0a24', alarm: { color: '#EE0A24', bg: '#FDECEC', label: '告警' },
}
function onClose() {
emit('update:show', false)
} }
</script> </script>
@@ -33,105 +37,154 @@ const statusColorMap: Record<string, string> = {
:show="show" :show="show"
position="bottom" position="bottom"
round round
:style="{ height: '40%' }" :style="{ height: '42%' }"
@update:show="emit('update:show', $event)" @update:show="emit('update:show', $event)"
> >
<div class="yb-pop"> <div class="pop-container">
<!-- Header -->
<div class="pop-header"> <div class="pop-header">
<span class="pop-title">实时仪表</span> <span class="pop-title">实时监测</span>
<van-icon name="cross" size="20" color="#999" @click="emit('update:show', false)" /> <span class="pop-subtitle">数据更新时间: 刚刚</span>
<van-icon name="cross" size="20" color="#C8C9CC" @click="onClose" />
</div> </div>
<!-- Body -->
<div class="pop-body"> <div class="pop-body">
<div <div
v-for="gauge in gauges" v-for="gauge in gauges"
:key="gauge.id" :key="gauge.id"
class="gauge-item" class="gauge-card"
:style="{ borderColor: statusCfg[gauge.status].color }"
> >
<span <div class="gauge-header">
class="gauge-dot" <span
:style="{ backgroundColor: statusColorMap[gauge.status] }" class="gauge-dot"
/> :style="{ backgroundColor: statusCfg[gauge.status].color }"
<div class="gauge-info"> />
<span class="gauge-label">{{ gauge.name }}</span> <span class="gauge-name">{{ gauge.name }}</span>
<span class="gauge-value"> <span
{{ gauge.value }} class="gauge-status"
<small>{{ gauge.unit }}</small> :style="{ color: statusCfg[gauge.status].color, background: statusCfg[gauge.status].bg }"
>
{{ statusCfg[gauge.status].label }}
</span> </span>
</div> </div>
<div class="gauge-value">
<span class="value-num">{{ gauge.value }}</span>
<span class="value-unit">{{ gauge.unit }}</span>
</div>
</div> </div>
</div> </div>
<!-- Safe Area -->
<div class="pop-safe-bottom" />
</div> </div>
</van-popup> </van-popup>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.yb-pop { .pop-container {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #F4F7F8;
}
.pop-header { // ── Header ──
display: flex; .pop-header {
justify-content: space-between; display: flex;
align-items: center; align-items: center;
padding: 16px 20px; gap: 8px;
border-bottom: 1px solid var(--color-border); padding: 18px 20px;
background: #fff;
border-bottom: 1px solid #EBEDF0;
flex-shrink: 0;
.pop-title { .pop-title {
font-size: 16px; font-size: 17px;
font-weight: 600; font-weight: 600;
color: var(--color-text-regular); color: #323233;
}
}
.pop-body {
flex: 1; flex: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px 20px;
} }
.gauge-item { .pop-subtitle {
width: calc(50% - 8px); font-size: 12px;
display: flex; color: #969799;
align-items: center;
background: #f7f8fa;
border-radius: 10px;
padding: 16px;
.gauge-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
}
.gauge-info {
display: flex;
flex-direction: column;
gap: 4px;
.gauge-label {
font-size: 13px;
color: var(--color-text-secondary);
}
.gauge-value {
font-size: 22px;
font-weight: 700;
color: var(--color-text-regular);
small {
font-size: 12px;
font-weight: 400;
color: var(--color-text-placeholder);
}
}
}
} }
} }
// ── Body ──
.pop-body {
flex: 1;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 12px;
padding: 16px 12px;
overflow-y: auto;
}
// ── Gauge Card ──
.gauge-card {
width: calc(50% - 6px);
background: #fff;
border-radius: 12px;
padding: 14px;
border-left: 3px solid #EBEDF0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 10px;
}
.gauge-header {
display: flex;
align-items: center;
gap: 8px;
.gauge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.gauge-name {
font-size: 13px;
color: #969799;
flex: 1;
}
.gauge-status {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
}
.gauge-value {
display: flex;
align-items: baseline;
gap: 4px;
.value-num {
font-size: 26px;
font-weight: 700;
color: #323233;
line-height: 1;
}
.value-unit {
font-size: 13px;
color: #969799;
}
}
// ── Safe Area ──
.pop-safe-bottom {
height: env(safe-area-inset-bottom, 12px);
flex-shrink: 0;
background: #F4F7F8;
}
</style> </style>

View File

@@ -5,27 +5,55 @@
* 展示工程项目的详细信息,包括基本信息、 * 展示工程项目的详细信息,包括基本信息、
* 施工进度、现场照片等。 * 施工进度、现场照片等。
*/ */
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const detailId = computed(() => route.params.id as string | undefined)
/** 模拟项目详情 */ interface ProjectDetail {
const project = { id: string
id: route.params.id, name: string
no: string
company: string
progress: number
status: 'building' | 'paused' | 'completed'
manager: string
phone: string
startDate: string
endDate: string
budget: string
address: string
description: string
}
const project = ref<ProjectDetail>({
id: detailId.value || '1',
name: '城北雨水管网改造工程', name: '城北雨水管网改造工程',
no: 'XM-2025-001', no: 'XM-2025-001',
company: '中建三局', company: '中建三局',
manager: '赵工',
phone: '13800001010',
progress: 75, progress: 75,
status: 'building', status: 'building',
manager: '赵工',
phone: '13800001010',
startDate: '2025-03-01', startDate: '2025-03-01',
endDate: '2025-09-30', endDate: '2025-12-31',
budget: '1250万元', budget: '1250万元',
address: '城北工业园区大道路段', address: '城北工业园区大道路段',
description: '该项目对城北工业园区范围内雨水管网进行全面改造升级,包括主管网更换、检查井增设、雨水口改造等内容。改造完成后将显著提升区域排水能力。', description: '该项目对城北工业园区范围内雨水管网进行全面改造升级,包括主管网更换、检查井增设、雨水口改造等内容。改造完成后将显著提升区域排水能力惠及周边30万居民。',
})
const statusCfg: Record<string, { label: string; color: string; bg: string }> = {
building: { label: '在建', color: '#1E74FF', bg: '#E3F2FD' },
paused: { label: '暂停', color: '#FF976A', bg: '#FFF3ED' },
completed: { label: '竣工', color: '#07C160', bg: '#E8F8EF' },
}
function progressColor(pct: number): string {
if (pct >= 80) return '#07C160'
if (pct >= 40) return '#1E74FF'
return '#FF976A'
} }
/** 模拟现场照片 */ /** 模拟现场照片 */
@@ -36,21 +64,21 @@ const photos = ref([
{ url: 'https://img.yzcdn.cn/vant/apple-3.jpg' }, { url: 'https://img.yzcdn.cn/vant/apple-3.jpg' },
]) ])
function progressColor(pct: number): string { /** 模拟里程碑 */
if (pct >= 80) return '#07c160' const milestones = ref([
if (pct >= 40) return '#1989fa' { text: '项目立项', time: '2025-02-15', done: true },
return '#ff976a' { text: '施工设计', time: '2025-03-15', done: true },
} { text: '管网铺设', time: '2025-06-30', done: true },
{ text: '泵站建设', time: '2025-09-30', done: false },
{ text: '竣工验收', time: '2025-12-31', done: false },
])
const statusMap: Record<string, string> = { const activeStep = computed(() => milestones.value.filter(m => m.done).length - 1)
building: '在建',
paused: '暂停',
completed: '竣工',
}
</script> </script>
<template> <template>
<div class="page-container"> <div class="page">
<!-- NavBar -->
<van-nav-bar <van-nav-bar
title="项目详情" title="项目详情"
left-arrow left-arrow
@@ -59,96 +87,288 @@ const statusMap: Record<string, string> = {
@click-left="router.back()" @click-left="router.back()"
/> />
<van-cell-group inset> <!-- Status Banner -->
<van-cell title="项目名称" :value="project.name" /> <div class="status-banner">
<van-cell title="项目编号" :value="project.no" /> <span
<van-cell title="施工单位" :value="project.company" /> class="status-badge"
<van-cell title="项目负责人" :value="project.manager" /> :style="{ color: statusCfg[project.status].color, background: statusCfg[project.status].bg }"
<van-cell title="联系电话" :value="project.phone" /> >
<van-cell title="项目状态"> {{ statusCfg[project.status].label }}
<template #value> </span>
<van-tag type="primary" size="medium">{{ statusMap[project.status] }}</van-tag> <span class="status-name">{{ project.name }}</span>
</template> </div>
</van-cell>
<van-cell title="施工地址" :value="project.address" />
<van-cell title="开工日期" :value="project.startDate" />
<van-cell title="计划竣工" :value="project.endDate" />
<van-cell title="项目预算" :value="project.budget" />
</van-cell-group>
<van-cell-group inset style="margin-top: 12px"> <!-- Section: 基本信息 -->
<van-cell title="施工进度" /> <div class="section">
<div class="progress-section"> <div class="section-header">
<span class="section-accent"></span>
<span class="section-title">基本信息</span>
</div>
<div class="card">
<div class="info-grid">
<div class="info-item">
<span class="info-label">项目编号</span>
<span class="info-value">{{ project.no }}</span>
</div>
<div class="info-item">
<span class="info-label">施工单位</span>
<span class="info-value">{{ project.company }}</span>
</div>
<div class="info-item">
<span class="info-label">负责人</span>
<span class="info-value">{{ project.manager }}</span>
</div>
<div class="info-item">
<span class="info-label">联系电话</span>
<span class="info-value">{{ project.phone }}</span>
</div>
<div class="info-item">
<span class="info-label">施工地址</span>
<span class="info-value">{{ project.address }}</span>
</div>
<div class="info-item">
<span class="info-label">计划工期</span>
<span class="info-value">{{ project.startDate }} ~ {{ project.endDate }}</span>
</div>
<div class="info-item full-width">
<span class="info-label">预算金额</span>
<span class="info-value highlight">{{ project.budget }}</span>
</div>
</div>
</div>
</div>
<!-- Section: 项目描述 -->
<div class="section">
<div class="section-header">
<span class="section-accent"></span>
<span class="section-title">项目描述</span>
</div>
<div class="card">
<p class="desc-text">{{ project.description }}</p>
</div>
</div>
<!-- Section: 工程进度 -->
<div class="section">
<div class="section-header">
<span class="section-accent"></span>
<span class="section-title">工程进度</span>
</div>
<div class="card">
<van-progress <van-progress
:percentage="project.progress" :percentage="project.progress"
:color="progressColor(project.progress)" :color="progressColor(project.progress)"
:pivot-text="`${project.progress}%`" :pivot-text="`${project.progress}%`"
stroke-width="12" stroke-width="10"
/>
</div>
</van-cell-group>
<van-cell-group inset style="margin-top: 12px">
<van-cell title="项目概述" />
<div class="content-block">{{ project.description }}</div>
</van-cell-group>
<div class="photo-section" v-if="photos.length > 0">
<div class="section-title">现场照片</div>
<div class="photo-grid">
<van-image
v-for="(photo, idx) in photos"
:key="idx"
:src="photo.url"
width="100%"
height="100"
fit="cover"
radius="8"
lazy-load
/> />
</div> </div>
</div> </div>
<!-- Section: 项目里程碑 -->
<div class="section">
<div class="section-header">
<span class="section-accent"></span>
<span class="section-title">项目里程碑</span>
</div>
<div class="card">
<van-steps
:active="activeStep"
direction="vertical"
active-color="#1E74FF"
>
<van-step v-for="(step, idx) in milestones" :key="idx">
<template #active-icon>
<van-icon name="checked" color="#1E74FF" />
</template>
<template #inactive-icon>
<van-icon name="clock-o" color="#C8C9CC" />
</template>
<div class="step-content">
<h4 :class="{ 'step-done': step.done }">{{ step.text }}</h4>
<p>{{ step.time }}</p>
</div>
</van-step>
</van-steps>
</div>
</div>
<!-- Section: 现场照片 -->
<div class="section" v-if="photos.length > 0">
<div class="section-header">
<span class="section-accent"></span>
<span class="section-title">现场照片</span>
</div>
<div class="card photo-card">
<div class="photo-grid">
<van-image
v-for="(photo, idx) in photos"
:key="idx"
:src="photo.url"
width="100%"
height="100"
fit="cover"
radius="8"
lazy-load
class="photo-item"
/>
</div>
</div>
</div>
<!-- Bottom spacing -->
<div class="bottom-spacer" />
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page {
min-height: 100vh; min-height: 100vh;
background: var(--color-bg-page); background: #F4F7F8;
padding-bottom: 24px; padding-bottom: 24px;
}
:deep(.van-nav-bar) { // ── NavBar ──
background: var(--color-primary); :deep(.van-nav-bar) {
--van-nav-bar-title-text-color: #fff; background: #1E74FF;
--van-nav-bar-text-color: #fff; --van-nav-bar-title-text-color: #fff;
--van-nav-bar-icon-color: #fff; --van-nav-bar-text-color: #fff;
--van-nav-bar-icon-color: #fff;
}
// ── Status Banner ──
.status-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
margin: 12px;
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
.status-badge {
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.status-name {
font-size: 15px;
font-weight: 500;
color: #323233;
} }
} }
.progress-section { // ── Section ──
padding: 12px 16px; .section {
background: var(--color-bg-card); margin: 0 12px 10px;
.section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
}
.section-accent {
width: 3px;
height: 16px;
border-radius: 2px;
background: #1E74FF;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #323233;
}
} }
.content-block { // ── Card ──
.card {
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
padding: 12px 16px; padding: 12px 16px;
}
// ── Info Grid ──
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.info-item {
padding: 10px 0;
border-bottom: 1px solid #F2F3F5;
display: flex;
flex-direction: column;
gap: 4px;
&:nth-child(odd) {
padding-right: 12px;
}
&.full-width {
grid-column: 1 / -1;
padding-right: 0;
}
.info-label {
font-size: 12px;
color: #969799;
}
.info-value {
font-size: 14px;
color: #323233;
word-break: break-all;
&.highlight {
font-weight: 700;
color: #EE0A24;
}
}
}
// ── Description ──
.desc-text {
font-size: 14px; font-size: 14px;
line-height: 1.8; color: #646566;
color: var(--color-text-regular); line-height: 1.7;
background: var(--color-bg-card); margin: 0;
white-space: pre-wrap;
} }
.photo-section { // ── Steps ──
margin-top: 12px; :deep(.van-step__title) {
padding: 12px 16px; .step-content {
h4 {
margin: 0;
font-size: 14px;
color: #969799;
font-weight: 400;
&.step-done {
color: #323233;
font-weight: 500;
}
}
p {
margin: 4px 0 0;
font-size: 12px;
color: #C8C9CC;
}
}
} }
.section-title { // ── Photos ──
font-size: 14px; .photo-card {
font-weight: 500; padding: 8px;
margin-bottom: 10px;
color: var(--color-text-primary);
} }
.photo-grid { .photo-grid {
@@ -156,4 +376,14 @@ const statusMap: Record<string, string> = {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 8px; gap: 8px;
} }
.photo-item {
border-radius: 8px;
overflow: hidden;
}
// ── Bottom Spacer ──
.bottom-spacer {
height: 16px;
}
</style> </style>

View File

@@ -9,41 +9,43 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const searchText = ref('') const searchText = ref('')
const activeTab = ref(0) const activeTab = ref(0)
/** 状态映射 */ interface ConstructionProject {
const statusMap: Record<string, string> = { id: number
building: '在建', name: string
paused: '暂停', no: string
completed: '竣工', company: string
progress: number
status: 'building' | 'paused' | 'completed'
manager: string
startDate: string
} }
const statusColorMap: Record<string, 'primary' | 'warning' | 'success'> = { const projects: ConstructionProject[] = [
building: 'primary',
paused: 'warning',
completed: 'success',
}
/** 进度颜色 */
function progressColor(pct: number): string {
if (pct >= 80) return '#07c160'
if (pct >= 40) return '#1989fa'
return '#ff976a'
}
/** 模拟项目数据 */
const mockProjects = [
{ id: 1, name: '城北雨水管网改造工程', no: 'XM-2025-001', company: '中建三局', progress: 75, status: 'building', manager: '赵工', startDate: '2025-03-01' }, { id: 1, name: '城北雨水管网改造工程', no: 'XM-2025-001', company: '中建三局', progress: 75, status: 'building', manager: '赵工', startDate: '2025-03-01' },
{ id: 2, name: '东风路排水泵站新建项目', no: 'XM-2025-002', company: '水务工程公司', progress: 30, status: 'building', manager: '钱工', startDate: '2025-04-15' }, { id: 2, name: '东风路排水泵站新建项目', no: 'XM-2025-002', company: '水务工程公司', progress: 30, status: 'building', manager: '钱工', startDate: '2025-04-15' },
{ id: 3, name: '中山河河道整治工程', no: 'XM-2025-003', company: '市政建设集团', progress: 90, status: 'building', manager: '孙工', startDate: '2025-01-10' }, { id: 3, name: '中山河河道整治工程', no: 'XM-2025-003', company: '市政建设集团', progress: 90, status: 'building', manager: '孙工', startDate: '2025-01-10' },
{ id: 4, name: '开发区污水管网铺设', no: 'XM-2025-004', company: '中交一公局', progress: 0, status: 'paused', manager: '李工', startDate: '2025-05-01' }, { id: 4, name: '开发区污水管网铺设', no: 'XM-2025-004', company: '中交一公局', progress: 0, status: 'paused', manager: '李工', startDate: '2025-05-01' },
{ id: 5, name: '老城区雨污分流改造', no: 'XM-2025-005', company: '中铁十二局', progress: 100, status: 'completed', manager: '周工', startDate: '2024-09-01' }, { id: 5, name: '老城区雨污分流改造', no: 'XM-2025-005', company: '中铁十二局', progress: 100, status: 'completed', manager: '周工', startDate: '2024-09-01' },
{ id: 6, name: '南湖片区海绵城市试点', no: 'XM-2025-006', company: '葛洲坝集团', progress: 55, status: 'building', manager: '吴工', startDate: '2025-06-01' },
] ]
const statusCfg: Record<string, { label: string; color: string; bg: string }> = {
building: { label: '在建', color: '#1E74FF', bg: '#E3F2FD' },
paused: { label: '暂停', color: '#FF976A', bg: '#FFF3ED' },
completed: { label: '竣工', color: '#07C160', bg: '#E8F8EF' },
}
function progressColor(pct: number): string {
if (pct >= 80) return '#07C160'
if (pct >= 40) return '#1E74FF'
return '#FF976A'
}
const filteredList = computed(() => { const filteredList = computed(() => {
let list = mockProjects let list = projects
if (searchText.value) { if (searchText.value) {
const kw = searchText.value.toLowerCase() const kw = searchText.value.toLowerCase()
list = list.filter(p => list = list.filter(p =>
@@ -64,85 +66,187 @@ function goDetail(id: number) {
</script> </script>
<template> <template>
<div class="page-container"> <div class="page">
<van-nav-bar title="工程项目" left-arrow fixed placeholder @click-left="router.back()" /> <!-- NavBar -->
<van-nav-bar
title="工程项目"
left-arrow
fixed
placeholder
@click-left="router.back()"
/>
<van-search v-model="searchText" placeholder="搜索项目名称、编号、单位" shape="round" /> <!-- Search -->
<van-search
v-model="searchText"
placeholder="搜索项目名称、编号、单位"
shape="round"
class="search-bar"
/>
<van-tabs v-model:active="activeTab" sticky> <!-- Tabs -->
<van-tabs v-model:active="activeTab" sticky swipeable class="tabs-bar">
<van-tab title="全部" /> <van-tab title="全部" />
<van-tab title="在建" /> <van-tab title="在建" />
<van-tab title="暂停" /> <van-tab title="暂停" />
<van-tab title="竣工" /> <van-tab title="竣工" />
</van-tabs> </van-tabs>
<div class="card-list"> <!-- Project List -->
<div class="content">
<van-empty v-if="filteredList.length === 0" description="暂无项目" /> <van-empty v-if="filteredList.length === 0" description="暂无项目" />
<van-card
<div
v-for="item in filteredList" v-for="item in filteredList"
:key="item.id" :key="item.id"
:title="item.name" class="proj-card"
:desc="`施工单位: ${item.company}`"
@click="goDetail(item.id)" @click="goDetail(item.id)"
> >
<template #tags> <div class="card-accent" :style="{ background: progressColor(item.progress) }" />
<van-tag :type="statusColorMap[item.status]" size="medium">
{{ statusMap[item.status] }} <div class="card-body">
</van-tag> <div class="card-header">
</template> <span class="card-title">{{ item.name }}</span>
<template #footer> <span
<div class="progress-wrap"> class="status-tag"
<van-progress :style="{ color: statusCfg[item.status].color, background: statusCfg[item.status].bg }"
:percentage="item.progress" >
:color="progressColor(item.progress)" {{ statusCfg[item.status].label }}
:pivot-text="`${item.progress}%`" </span>
/>
</div> </div>
<div class="card-meta">
<span>{{ item.no }}</span> <div class="card-info">
<span>负责人: {{ item.manager }}</span> <span class="info-text">{{ item.no }}</span>
<span class="info-divider">|</span>
<span class="info-text">{{ item.company }}</span>
<span class="info-divider">|</span>
<span class="info-text">负责人: {{ item.manager }}</span>
</div> </div>
</template>
</van-card> <van-progress
:percentage="item.progress"
:color="progressColor(item.progress)"
:pivot-text="`${item.progress}%`"
stroke-width="6"
/>
<van-icon name="arrow" color="#C8C9CC" class="card-arrow" />
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page {
min-height: 100vh; min-height: 100vh;
background: var(--color-bg-page); background: #F4F7F8;
}
:deep(.van-nav-bar) { // ── NavBar ──
background: var(--color-primary); :deep(.van-nav-bar) {
--van-nav-bar-title-text-color: #fff; background: #1E74FF;
--van-nav-bar-text-color: #fff; --van-nav-bar-title-text-color: #fff;
--van-nav-bar-icon-color: #fff; --van-nav-bar-text-color: #fff;
--van-nav-bar-icon-color: #fff;
}
// ── Search ──
.search-bar {
:deep(.van-search__content) {
background: #fff;
} }
} }
.card-list { // ── Tabs ──
padding: 0 8px; .tabs-bar {
:deep(.van-tabs__nav) {
:deep(.van-card) { background: #fff;
margin: 8px;
border-radius: 10px;
background: var(--color-bg-card);
}
:deep(.van-tag) {
margin-right: 4px;
} }
} }
.progress-wrap { // ── Content ──
margin: 8px 0; .content {
padding: 8px 12px;
} }
.card-meta { // ── Card ──
.proj-card {
display: flex; display: flex;
gap: 12px; position: relative;
font-size: 12px; margin-bottom: 10px;
color: var(--color-text-secondary); background: #fff;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow: hidden;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
&:active {
transform: scale(0.985);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.card-accent {
width: 4px;
flex-shrink: 0;
}
.card-body {
flex: 1;
padding: 14px 16px;
position: relative;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
.card-title {
font-size: 16px;
font-weight: 600;
color: #323233;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
}
.card-info {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
.info-text {
font-size: 12px;
color: #969799;
}
.info-divider {
font-size: 12px;
color: #EBEDF0;
}
}
.card-arrow {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
} }
</style> </style>