feat: 新增 IoT API 接口、扫码页面和启动页

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-28 14:31:26 +08:00
parent e5a64fc322
commit c9979da18d
9 changed files with 1712 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 工单 VO */
export interface WorkOrderVO {
id: number
code: string
title: string
area: string
description: string
images?: string[]
status: number
priority: number
creatorName?: string
assigneeName?: string
createTime: string
assignTime?: string
acceptTime?: string
completeTime?: string
}
/** 工单状态枚举 */
export const WorkOrderStatus = {
PENDING: 0, // 待派发
PROCESSING: 1, // 进行中
COMPLETED: 2, // 已完成
CLOSED: 3, // 已关闭
} as const
/** 工单优先级枚举 */
export const WorkOrderPriority = {
P0: 0, // 紧急
P1: 1, // 高优
P2: 2, // 普通
} as const
/** 获取工单详情 */
export function getWorkOrder(id: number) {
return http.get<WorkOrderVO>(`/iot/work-order/get?id=${id}`)
}
/** 获取工单分页列表 */
export function getWorkOrderPage(params: PageParam) {
return http.get<PageResult<WorkOrderVO>>('/iot/work-order/page', params)
}
/** 创建工单 */
export function createWorkOrder(data: Partial<WorkOrderVO>) {
return http.post<number>('/iot/work-order/create', data)
}
/** 派发工单 */
export function assignWorkOrder(id: number, assigneeId: number) {
return http.put<boolean>('/iot/work-order/assign', { id, assigneeId })
}
/** 接单 */
export function acceptWorkOrder(id: number) {
return http.put<boolean>(`/iot/work-order/accept?id=${id}`)
}
/** 标记完成 */
export function completeWorkOrder(id: number) {
return http.put<boolean>(`/iot/work-order/complete?id=${id}`)
}

View File

@@ -0,0 +1,160 @@
<template>
<view class="splash-container">
<!-- 装饰图案 -->
<view class="splash-pattern" />
<!-- 品牌 Logo -->
<view class="logo-area">
<view class="logo-box">
<image src="/static/logo-white.svg" mode="aspectFit" class="h-80rpx w-80rpx" />
</view>
<view class="mt-40rpx text-56rpx text-white font-900" style="letter-spacing: 8rpx;">
AIOT
</view>
<view class="mt-16rpx text-28rpx text-white/70 font-600">
智慧物业管理平台
</view>
</view>
<!-- 加载动画 -->
<view class="loading-area">
<view class="loading-dots">
<view class="dot" />
<view class="dot" />
<view class="dot" />
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { LOGIN_PAGE } from '@/router/config'
import { useTokenStore } from '@/store/token'
definePage({
style: {
navigationStyle: 'custom',
},
meta: {
excludeLoginPath: true,
},
})
const tokenStore = useTokenStore()
onMounted(() => {
setTimeout(() => {
if (tokenStore.hasLogin) {
uni.reLaunch({ url: '/pages/index/index' })
} else {
uni.reLaunch({ url: LOGIN_PAGE })
}
}, 2000)
})
</script>
<style lang="scss" scoped>
.splash-container {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fbbf24 0%, #f97316 100%);
position: relative;
overflow: hidden;
}
.splash-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
background-size: 24px 24px;
&::before {
content: '';
position: absolute;
top: -120rpx;
right: -120rpx;
width: 400rpx;
height: 400rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
}
&::after {
content: '';
position: absolute;
bottom: -80rpx;
left: -80rpx;
width: 300rpx;
height: 300rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
}
}
.logo-area {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
.logo-box {
width: 160rpx;
height: 160rpx;
border-radius: 56rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.1);
}
.loading-area {
position: absolute;
bottom: 200rpx;
z-index: 1;
}
.loading-dots {
display: flex;
gap: 16rpx;
}
.dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
&:nth-child(3) {
animation-delay: 0s;
}
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>

141
src/pages/scan/index.vue Normal file
View File

@@ -0,0 +1,141 @@
<template>
<view class="yd-page-container">
<wd-navbar
title="扫码巡检"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="handleBack"
/>
<view class="flex flex-1 flex-col items-center px-40rpx pt-80rpx">
<!-- 扫码图标区域 -->
<view
class="mb-48rpx h-240rpx w-240rpx flex items-center justify-center rounded-full bg-[#FFF7ED]"
@click="handleScan"
>
<view class="i-carbon-qr-code text-120rpx text-[#F97316]" />
</view>
<!-- 提示文字 -->
<text class="mb-16rpx text-32rpx text-[#1F2937] font-bold">扫描设备二维码</text>
<text class="mb-80rpx text-26rpx text-[#9CA3AF]">对准设备上的二维码/条形码快速开始巡检</text>
<!-- 扫码按钮 -->
<view class="w-full" @click="handleScan">
<view class="ai-btn-primary flex items-center justify-center">
<view class="i-carbon-scan mr-12rpx text-36rpx text-white" />
<text>开始扫码</text>
</view>
</view>
<!-- 手动输入 -->
<view class="mt-32rpx w-full" @click="handleManualInput">
<view
class="flex items-center justify-center border-2rpx border-[#F97316] rounded-24rpx bg-white py-28rpx text-28rpx text-[#F97316] font-bold"
>
<view class="i-carbon-text-input mr-12rpx text-36rpx" />
<text>手动输入编号</text>
</view>
</view>
<!-- 最近扫码记录 -->
<view v-if="recentScans.length > 0" class="mt-64rpx w-full">
<text class="mb-24rpx text-26rpx text-[#9CA3AF] font-600">最近扫码</text>
<view class="ai-card mt-16rpx overflow-hidden">
<view
v-for="(item, index) in recentScans"
:key="item.code"
class="flex items-center px-32rpx py-24rpx"
:class="{ 'border-t-2rpx border-[#FFF7ED]': index > 0 }"
@click="goInspection(item.code)"
>
<view class="mr-20rpx h-64rpx w-64rpx flex items-center justify-center rounded-16rpx bg-[#FFF7ED]">
<view class="i-carbon-qr-code text-32rpx text-[#F97316]" />
</view>
<view class="flex-1">
<text class="text-28rpx text-[#1F2937] font-600">{{ item.code }}</text>
<text class="mt-4rpx block text-22rpx text-[#9CA3AF]">{{ item.time }}</text>
</view>
<wd-icon name="arrow-right" size="16px" color="#D1D5DB" />
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { useToast } from 'wot-design-uni'
definePage({
style: {
navigationBarTitleText: '扫码巡检',
},
})
const toast = useToast()
interface ScanRecord {
code: string
time: string
}
const recentScans = ref<ScanRecord[]>([])
/** 页面显示时自动调起扫码 */
let isFirstShow = true
onShow(() => {
if (isFirstShow) {
isFirstShow = false
handleScan()
}
})
function handleBack() {
uni.navigateBack()
}
/** 调用扫码 */
function handleScan() {
uni.scanCode({
onlyFromCamera: false,
scanType: ['qrCode', 'barCode'],
success: (res) => {
addRecentScan(res.result)
goInspection(res.result)
},
fail: () => {
toast.info('已取消扫码')
},
})
}
/** 手动输入 */
function handleManualInput() {
uni.navigateTo({
url: '/pages/scan/inspection/index',
})
}
/** 跳转巡检详情 */
function goInspection(code: string) {
uni.navigateTo({
url: `/pages/scan/inspection/index?code=${encodeURIComponent(code)}`,
})
}
/** 添加最近扫码记录 */
function addRecentScan(code: string) {
const now = new Date()
const time = `${now.getMonth() + 1}-${now.getDate()} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
// 去重并放到最前
recentScans.value = [
{ code, time },
...recentScans.value.filter(item => item.code !== code),
].slice(0, 5)
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<view class="yd-page-container">
<wd-navbar
title="巡检记录"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="uni.navigateBack()"
/>
<!-- 巡检区域卡片 -->
<view class="mx-32rpx mt-24rpx">
<view class="ai-gradient-header rounded-48rpx p-36rpx">
<view class="flex items-center justify-between">
<view>
<view class="text-32rpx text-white font-bold">
{{ areaName || 'A栋大堂' }}
</view>
<view class="mt-8rpx text-24rpx text-white/80">
巡检编码{{ code || 'QR-2024-001' }}
</view>
</view>
<view
class="rounded-999px bg-white/20 px-24rpx py-12rpx text-24rpx text-white font-600"
@click="handleSwitchArea"
>
切换区域
</view>
</view>
</view>
</view>
<!-- 检查清单 -->
<view class="ai-card mx-32rpx mt-24rpx p-36rpx">
<view class="mb-24rpx text-32rpx text-[#1F2937] font-bold">
检查清单
</view>
<view v-for="(item, index) in checklist" :key="index" class="check-item">
<view class="flex items-center justify-between py-20rpx" @click="toggleCheck(index)">
<view class="flex items-center">
<view
class="mr-16rpx h-40rpx w-40rpx flex items-center justify-center rounded-full"
:class="item.checked ? 'bg-[#F97316]' : 'bg-[#F3F4F6]'"
>
<view v-if="item.checked" class="i-carbon-checkmark text-16px text-white" />
</view>
<text class="text-28rpx text-[#1F2937]">{{ item.label }}</text>
</view>
<text class="text-22rpx font-600" :class="item.checked ? 'text-[#F97316]' : 'text-[#9CA3AF]'">
{{ item.checked ? '合格' : '待检' }}
</text>
</view>
</view>
<!-- 全部合格快捷操作 -->
<view
class="mt-16rpx flex items-center justify-center rounded-48rpx py-20rpx"
style="background: rgba(249, 115, 22, 0.06);"
@click="checkAll"
>
<view class="i-carbon-checkmark-outline mr-8rpx text-[#F97316]" />
<text class="text-28rpx text-[#F97316] font-600">全部合格</text>
</view>
</view>
<!-- 问题描述 -->
<view class="ai-card mx-32rpx mt-24rpx p-36rpx">
<view class="mb-24rpx text-32rpx text-[#1F2937] font-bold">
问题描述
</view>
<view class="textarea-wrap">
<wd-textarea
v-model="remark"
placeholder="如有异常情况请在此描述..."
:maxlength="500"
clearable
/>
</view>
<view class="mt-24rpx flex gap-16rpx">
<view class="media-btn" @click="handleTakePhoto">
<view class="i-carbon-camera text-40rpx text-[#F97316]" />
<text class="mt-8rpx text-22rpx text-[#9CA3AF] font-600">拍照</text>
</view>
<view
v-for="(photo, index) in photos"
:key="index"
class="relative h-160rpx w-160rpx overflow-hidden rounded-24rpx"
>
<image :src="photo" mode="aspectFill" class="h-full w-full" />
<view
class="absolute right-0 top-0 h-40rpx w-40rpx flex items-center justify-center rounded-bl-24rpx bg-black/50"
@click="removePhoto(index)"
>
<view class="i-carbon-close text-12px text-white" />
</view>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="mx-32rpx mt-48rpx">
<view class="ai-btn-primary" @click="handleSubmit">
提交巡检记录
</view>
</view>
<!-- 底部留白 -->
<view class="h-48rpx pb-safe" />
</view>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const code = ref('')
const areaName = ref('')
const remark = ref('')
const photos = ref<string[]>([])
const checklist = reactive([
{ label: '地面清洁情况', checked: false },
{ label: '垃圾桶是否已清理', checked: false },
{ label: '物资摆放是否整齐', checked: false },
{ label: '空气质量是否正常', checked: false },
])
onLoad((options) => {
if (options?.code) {
code.value = decodeURIComponent(options.code)
areaName.value = `区域 ${code.value.slice(-3)}`
}
})
function toggleCheck(index: number) {
checklist[index].checked = !checklist[index].checked
}
function checkAll() {
checklist.forEach(item => (item.checked = true))
toast.success('已全部标记为合格')
}
function handleSwitchArea() {
toast.info('切换区域功能开发中')
}
function handleTakePhoto() {
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: (res) => {
photos.value.push(res.tempFilePaths[0])
},
})
}
function removePhoto(index: number) {
photos.value.splice(index, 1)
}
function handleSubmit() {
const unchecked = checklist.filter(item => !item.checked)
if (unchecked.length > 0 && !remark.value) {
toast.warning('存在未通过项,请填写问题描述')
return
}
toast.success('巡检记录已提交')
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script>
<style lang="scss" scoped>
.check-item + .check-item {
border-top: 2rpx solid #fff7ed;
}
.textarea-wrap {
background: rgba(249, 115, 22, 0.06);
border-radius: 48rpx;
padding: 24rpx;
}
.media-btn {
width: 160rpx;
height: 160rpx;
background: #fff;
border: 2rpx solid #ffedd5;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<view class="yd-page-container">
<wd-navbar
title="员工管理"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="uni.navigateBack()"
/>
<!-- 搜索框 -->
<view class="mx-32rpx mt-24rpx">
<view class="search-wrap">
<view class="i-carbon-search text-16px text-[#9CA3AF]" />
<input
v-model="keyword"
class="search-input"
placeholder="搜索员工姓名"
placeholder-class="text-[#D1D5DB]"
confirm-type="search"
@confirm="handleSearch"
>
<view v-if="keyword" class="i-carbon-close-filled text-14px text-[#D1D5DB]" @click="keyword = ''" />
</view>
</view>
<!-- 统计概览 -->
<view class="mx-32rpx mt-24rpx flex gap-24rpx">
<view class="ai-card flex-1 p-32rpx text-center">
<view class="text-48rpx text-[#F97316] font-900 tracking-tight">
{{ stats.onDuty }}
</view>
<view class="mt-8rpx text-22rpx text-[#9CA3AF] font-600">
在岗人数
</view>
</view>
<view class="ai-card flex-1 p-32rpx text-center">
<view class="text-48rpx text-[#22C55E] font-900 tracking-tight">
{{ stats.activeRate }}%
</view>
<view class="mt-8rpx text-22rpx text-[#9CA3AF] font-600">
活跃率
</view>
</view>
</view>
<!-- 员工列表 -->
<view class="px-32rpx pt-24rpx">
<view
v-for="staff in filteredStaff"
:key="staff.id"
class="ai-card mb-24rpx flex items-center p-32rpx"
>
<!-- 头像 + 状态灯 -->
<view class="relative mr-20rpx">
<view class="h-96rpx w-96rpx overflow-hidden rounded-full bg-[#FFF7ED]">
<image :src="staff.avatar" mode="aspectFill" class="h-full w-full" />
</view>
<view
class="absolute bottom-0 right-0 h-20rpx w-20rpx border-4rpx border-white rounded-full"
:class="staff.online ? 'bg-[#22C55E]' : 'bg-[#D1D5DB]'"
/>
</view>
<!-- 信息 -->
<view class="flex-1">
<view class="flex items-center">
<text class="text-28rpx text-[#1F2937] font-bold">{{ staff.name }}</text>
<view
class="ml-12rpx rounded-999px px-12rpx py-2rpx text-22rpx font-600"
:class="staff.online ? 'bg-[#F0FDF4] text-[#22C55E]' : 'bg-[#F9FAFB] text-[#9CA3AF]'"
>
{{ staff.online ? '在线' : '离线' }}
</view>
</view>
<view class="mt-8rpx text-24rpx text-[#9CA3AF]">
{{ staff.location }} · 当前 {{ staff.taskCount }} 个任务
</view>
</view>
<!-- 右侧箭头 -->
<wd-icon name="arrow-right" size="16px" color="#D1D5DB" />
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const keyword = ref('')
const stats = ref({
onDuty: 32,
activeRate: 87,
})
interface Staff {
id: number
name: string
avatar: string
online: boolean
location: string
taskCount: number
}
const staffList = ref<Staff[]>([
{ id: 1, name: '张明', avatar: 'https://i.pravatar.cc/150?img=1', online: true, location: 'A栋大堂', taskCount: 3 },
{ id: 2, name: '李华', avatar: 'https://i.pravatar.cc/150?img=2', online: true, location: 'B栋3层', taskCount: 2 },
{ id: 3, name: '王芳', avatar: 'https://i.pravatar.cc/150?img=3', online: false, location: 'C区花园', taskCount: 0 },
{ id: 4, name: '赵强', avatar: 'https://i.pravatar.cc/150?img=4', online: true, location: 'B1停车场', taskCount: 5 },
])
const filteredStaff = computed(() => {
if (!keyword.value)
return staffList.value
return staffList.value.filter(s => s.name.includes(keyword.value))
})
function handleSearch() {}
</script>
<style lang="scss" scoped>
.search-wrap {
display: flex;
align-items: center;
gap: 16rpx;
background: #fff;
border-radius: 48rpx;
padding: 18rpx 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1f2937;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<view class="timeline">
<view
v-for="(step, index) in steps"
:key="step.key"
class="timeline-item"
:class="{ 'timeline-item--last': index === steps.length - 1 }"
>
<!-- 左侧节点 + 连线 -->
<view class="timeline-left">
<!-- 圆形节点 -->
<view
class="timeline-dot"
:class="{
'timeline-dot--done': step.state === 'done',
'timeline-dot--current': step.state === 'current',
'timeline-dot--pending': step.state === 'pending',
}"
>
<view v-if="step.state === 'done'" class="i-carbon-checkmark text-20rpx text-white" />
</view>
<!-- 连线 -->
<view
v-if="index < steps.length - 1"
class="timeline-line"
:class="{
'timeline-line--done': step.state === 'done',
'timeline-line--pending': step.state !== 'done',
}"
/>
</view>
<!-- 右侧内容 -->
<view class="timeline-content">
<view class="flex items-center justify-between">
<text
class="text-28rpx font-bold"
:class="step.state === 'pending' ? 'text-[#D1D5DB]' : 'text-[#1F2937]'"
>
{{ step.label }}
</text>
<text v-if="step.time" class="text-22rpx text-[#9CA3AF]">
{{ step.time }}
</text>
</view>
<text v-if="step.operator" class="mt-4rpx text-24rpx text-[#9CA3AF]">
{{ step.operator }}
</text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
status: number
createTime?: string
assignTime?: string
acceptTime?: string
completeTime?: string
creatorName?: string
assigneeName?: string
}>()
type StepState = 'done' | 'current' | 'pending'
interface TimelineStep {
key: string
label: string
state: StepState
time?: string
operator?: string
}
const steps = computed<TimelineStep[]>(() => {
// status: 0-待派发 1-进行中 2-已完成 3-已关闭
const s = props.status
function getState(stepIndex: number): StepState {
// 步骤映射: 0=创建 1=已派发 2=处理中 3=已完成
if (s >= 2 && stepIndex <= 3)
return 'done' // 已完成,全部 done
if (s === 1 && stepIndex <= 1)
return 'done' // 进行中,创建+派发 done
if (s === 1 && stepIndex === 2)
return 'current'
if (s === 0 && stepIndex === 0)
return 'done' // 待派发,创建 done
if (s === 0 && stepIndex === 1)
return 'current'
return 'pending'
}
return [
{
key: 'created',
label: '创建工单',
state: getState(0),
time: props.createTime ? formatDateTime(props.createTime) : undefined,
operator: props.creatorName,
},
{
key: 'assigned',
label: '已派发',
state: getState(1),
time: props.assignTime ? formatDateTime(props.assignTime) : undefined,
operator: props.assigneeName ? `负责人: ${props.assigneeName}` : undefined,
},
{
key: 'processing',
label: '处理中',
state: getState(2),
time: props.acceptTime ? formatDateTime(props.acceptTime) : undefined,
},
{
key: 'completed',
label: '已完成',
state: getState(3),
time: props.completeTime ? formatDateTime(props.completeTime) : undefined,
},
]
})
</script>
<style lang="scss" scoped>
.timeline {
position: relative;
}
.timeline-item {
display: flex;
padding-bottom: 32rpx;
&--last {
padding-bottom: 0;
}
}
.timeline-left {
display: flex;
flex-direction: column;
align-items: center;
margin-right: 24rpx;
}
.timeline-dot {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--done {
background: #f97316;
}
&--current {
background: transparent;
border: 4rpx solid #f97316;
box-shadow: 0 0 0 6rpx rgba(249, 115, 22, 0.2);
animation: pulse 2s infinite;
}
&--pending {
background: transparent;
border: 4rpx dashed #d1d5db;
}
}
.timeline-line {
width: 4rpx;
flex: 1;
margin-top: 8rpx;
min-height: 24rpx;
&--done {
background: #f97316;
}
&--pending {
background: #e5e7eb;
border-left: 2rpx dashed #d1d5db;
width: 0;
}
}
.timeline-content {
flex: 1;
padding-top: 2rpx;
min-height: 48rpx;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 6rpx rgba(249, 115, 22, 0.2);
}
50% {
box-shadow: 0 0 0 12rpx rgba(249, 115, 22, 0.08);
}
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<view class="yd-page-container">
<wd-navbar
title="新建工单"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="handleBack"
/>
<view class="ai-card mx-32rpx mt-24rpx p-48rpx">
<!-- 区域选择 -->
<view class="mb-40rpx">
<view class="mb-12rpx text-22rpx text-[#9CA3AF] font-600" style="letter-spacing: 4rpx;">
区域
</view>
<wd-picker
v-model="formData.area"
:columns="areaOptions"
label=""
placeholder="请选择区域"
/>
</view>
<!-- 工单描述 -->
<view class="mb-40rpx">
<view class="mb-12rpx text-22rpx text-[#9CA3AF] font-600" style="letter-spacing: 4rpx;">
问题描述
</view>
<view class="textarea-wrap">
<wd-textarea
v-model="formData.description"
placeholder="请描述问题详情..."
:maxlength="500"
clearable
/>
</view>
</view>
<!-- 拍照 -->
<view class="mb-40rpx">
<view class="mb-12rpx text-22rpx text-[#9CA3AF] font-600" style="letter-spacing: 4rpx;">
现场照片
</view>
<view class="flex gap-16rpx">
<view class="media-btn" @click="handleTakePhoto">
<view class="i-carbon-camera text-40rpx text-[#F97316]" />
</view>
<view
v-for="(photo, index) in photos"
:key="index"
class="relative h-160rpx w-160rpx overflow-hidden rounded-24rpx"
>
<image :src="photo" mode="aspectFill" class="h-full w-full" />
<view
class="absolute right-0 top-0 h-40rpx w-40rpx flex items-center justify-center rounded-bl-24rpx bg-black/50"
@click="removePhoto(index)"
>
<view class="i-carbon-close text-12px text-white" />
</view>
</view>
</view>
</view>
<!-- 优先级 -->
<view class="mb-32rpx">
<view class="mb-12rpx text-22rpx text-[#9CA3AF] font-600" style="letter-spacing: 4rpx;">
优先级
</view>
<view class="flex gap-16rpx">
<view
v-for="p in priorities"
:key="p.value"
class="priority-btn"
:class="formData.priority === p.value ? getPriorityActiveClass(p.value) : ''"
@click="formData.priority = p.value"
>
{{ p.label }}
</view>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="mx-32rpx my-48rpx">
<view class="ai-btn-primary" @click="handleSubmit">
确认派单
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const photos = ref<string[]>([])
function handleBack() {
uni.navigateBack()
}
const formData = reactive({
area: '',
description: '',
priority: 'P1',
})
const areaOptions = ['A栋大堂', 'B栋电梯', 'C区花园', 'B1停车场', 'D栋走廊']
const priorities = [
{ label: 'P0 紧急', value: 'P0' },
{ label: 'P1 高优', value: 'P1' },
{ label: 'P2 普通', value: 'P2' },
]
function getPriorityActiveClass(value: string) {
if (value === 'P0')
return 'priority-btn--red'
return 'priority-btn--orange'
}
function handleTakePhoto() {
uni.chooseImage({
count: 1,
sourceType: ['camera', 'album'],
success: (res) => {
photos.value.push(res.tempFilePaths[0])
},
})
}
function removePhoto(index: number) {
photos.value.splice(index, 1)
}
function handleSubmit() {
if (!formData.area) {
toast.warning('请选择区域')
return
}
if (!formData.description) {
toast.warning('请填写问题描述')
return
}
toast.success('工单已创建')
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script>
<style lang="scss" scoped>
.textarea-wrap {
background: rgba(249, 115, 22, 0.06);
border-radius: 48rpx;
padding: 24rpx;
}
.media-btn {
width: 160rpx;
height: 160rpx;
background: #fff;
border: 2rpx solid #ffedd5;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.priority-btn {
flex: 1;
background: rgba(249, 115, 22, 0.06);
border-radius: 48rpx;
padding: 24rpx 0;
text-align: center;
font-size: 22rpx;
font-weight: 600;
color: #9ca3af;
border: 2rpx solid transparent;
transition: all 0.2s ease;
&--orange {
background: #f97316;
color: #fff;
border-color: #f97316;
}
&--red {
background: #ef4444;
color: #fff;
border-color: #ef4444;
}
}
</style>

View File

@@ -0,0 +1,391 @@
<template>
<view class="yd-page-container pb-[180rpx]">
<wd-navbar
title="工单详情"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="handleBack"
/>
<view class="mx-32rpx mt-24rpx">
<view class="ai-card p-36rpx">
<view class="flex items-start justify-between">
<view class="flex-1 pr-24rpx">
<view class="text-32rpx text-[#1F2937] font-bold">
{{ orderDetail.title }}
</view>
<view class="mt-12rpx text-24rpx text-[#9CA3AF]">
{{ orderDetail.area }} · {{ orderDetail.description }}
</view>
</view>
<view
class="shrink-0 rounded-999px px-20rpx py-6rpx text-22rpx font-bold"
:class="getStatusClass(orderDetail.status)"
>
{{ getStatusText(orderDetail.status) }}
</view>
</view>
<view class="mt-24rpx flex items-center justify-between border-t-2rpx border-[#FFF7ED] pt-24rpx">
<view class="flex items-center">
<view
class="mr-8rpx h-12rpx w-12rpx rounded-full"
:class="getPriorityColor(orderDetail.priority)"
/>
<text class="text-24rpx text-[#9CA3AF] font-600">{{ orderDetail.priority }}</text>
</view>
<text class="text-24rpx text-[#D1D5DB]">{{ orderDetail.time }}</text>
</view>
</view>
</view>
<view class="mx-32rpx mt-24rpx">
<view class="ai-card p-36rpx">
<view class="mb-24rpx text-28rpx text-[#333] font-bold">
基础信息
</view>
<view class="info-grid">
<view class="info-item">
<text class="info-label">工单编号</text>
<text class="info-value">{{ orderDetail.orderNo }}</text>
</view>
<view class="info-item">
<text class="info-label">工单类型</text>
<text class="info-value">{{ orderDetail.type }}</text>
</view>
<view class="info-item">
<text class="info-label">所属区域</text>
<text class="info-value">{{ orderDetail.area }}</text>
</view>
<view class="info-item">
<text class="info-label">创建时间</text>
<text class="info-value">{{ orderDetail.createTime }}</text>
</view>
<view class="info-item">
<text class="info-label">负责人</text>
<text class="info-value">{{ orderDetail.assignee || '待分配' }}</text>
</view>
<view class="info-item">
<text class="info-label">联系电话</text>
<text class="info-value">{{ orderDetail.phone || '-' }}</text>
</view>
</view>
<view class="mt-32rpx border-t-2rpx border-[#FFF7ED] pt-24rpx">
<text class="info-label">问题描述</text>
<text class="mt-12rpx block text-26rpx text-[#333] leading-relaxed">
{{ orderDetail.description }}
</text>
</view>
<view v-if="orderDetail.photos && orderDetail.photos.length > 0" class="mt-32rpx border-t-2rpx border-[#FFF7ED] pt-24rpx">
<text class="info-label">现场照片</text>
<view class="mt-16rpx flex flex-wrap gap-16rpx">
<image
v-for="(photo, index) in orderDetail.photos"
:key="index"
:src="photo"
mode="aspectFill"
class="h-160rpx w-160rpx rounded-16rpx"
@click="previewImage(index)"
/>
</view>
</view>
</view>
</view>
<view class="mx-32rpx mt-24rpx">
<view class="ai-card p-36rpx">
<view class="mb-24rpx text-28rpx text-[#333] font-bold">
工单进度
</view>
<view class="timeline">
<view
v-for="(step, index) in orderDetail.progress"
:key="index"
class="timeline-item"
:class="{ 'timeline-item--last': index === orderDetail.progress.length - 1 }"
>
<view class="timeline-dot" :class="getTimelineDotClass(step.status)">
<view v-if="step.status === 'done'" class="i-carbon-checkmark text-20rpx text-white" />
<view v-else-if="step.status === 'current'" class="i-carbon-time text-20rpx text-white" />
</view>
<view class="timeline-content">
<view class="flex items-center justify-between">
<text class="text-26rpx text-[#333] font-600">{{ step.title }}</text>
<text class="text-22rpx text-[#9CA3AF]">{{ step.time }}</text>
</view>
<text v-if="step.description" class="mt-8rpx block text-24rpx text-[#9CA3AF]">
{{ step.description }}
</text>
<view v-if="step.operator" class="mt-8rpx flex items-center">
<view class="mr-8rpx h-36rpx w-36rpx flex items-center justify-center rounded-full bg-[#F97316] text-20rpx text-white">
{{ step.operator.charAt(0) }}
</view>
<text class="text-24rpx text-[#666]">{{ step.operator }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<view
v-if="orderDetail.status === 'pending'"
class="ai-btn-primary flex-1"
@click="handleAssign"
>
指派人员
</view>
<view
v-if="orderDetail.status === 'processing'"
class="flex flex-1 gap-24rpx"
>
<view class="flex-1 rounded-24rpx bg-[#F5F5F5] py-28rpx text-center text-28rpx text-[#666] font-bold" @click="handleTransfer">
转派
</view>
<view class="ai-btn-primary flex-1" @click="handleComplete">
完成工单
</view>
</view>
<view
v-if="orderDetail.status === 'done'"
class="flex flex-1 gap-24rpx"
>
<view class="flex-1 rounded-24rpx bg-[#F5F5F5] py-28rpx text-center text-28rpx text-[#666] font-bold" @click="handleReopen">
重新开启
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
interface ProgressStep {
title: string
time: string
status: 'done' | 'current' | 'pending'
description?: string
operator?: string
}
interface WorkOrderDetail {
id: number
orderNo: string
title: string
type: string
area: string
description: string
status: 'pending' | 'processing' | 'done'
priority: string
time: string
createTime: string
assignee?: string
phone?: string
photos?: string[]
progress: ProgressStep[]
}
const props = defineProps<{
id: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
function handleBack() {
uni.navigateBack()
}
const orderDetail = ref<WorkOrderDetail>({
id: 1,
orderNo: 'WO202502120001',
title: '大堂地面清洁',
type: '清洁维护',
area: 'A栋大堂',
description: '地面有明显污渍需要清理位于大堂正门入口处面积约50平方米。污渍类型为饮料泼洒需要专业清洁剂处理。',
status: 'processing',
priority: 'P0-紧急',
time: '10分钟前',
createTime: '2025-02-12 09:30:00',
assignee: '张三',
phone: '138****8888',
photos: [
'https://via.placeholder.com/200x200?text=Photo1',
'https://via.placeholder.com/200x200?text=Photo2',
],
progress: [
{ title: '工单创建', time: '02-12 09:30', status: 'done', description: '系统自动创建工单', operator: '系统' },
{ title: '工单派发', time: '02-12 09:35', status: 'done', description: '已派发给清洁组', operator: '李四' },
{ title: '人员接单', time: '02-12 09:40', status: 'done', description: '张三已接单', operator: '张三' },
{ title: '处理中', time: '进行中', status: 'current', description: '正在前往现场处理', operator: '张三' },
{ title: '工单完成', time: '', status: 'pending' },
],
})
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待派发', processing: '进行中', done: '已完成' }
return map[status] || status
}
function getStatusClass(status: string) {
const map: Record<string, string> = {
pending: 'ai-badge--orange',
processing: 'bg-[#EFF6FF] text-[#3B82F6]',
done: 'ai-badge--green',
}
return map[status] || ''
}
function getPriorityColor(priority: string) {
if (priority.includes('P0'))
return 'bg-[#EF4444]'
if (priority.includes('P1'))
return 'bg-[#F97316]'
return 'bg-[#9CA3AF]'
}
function getTimelineDotClass(status: string) {
const map: Record<string, string> = {
done: 'timeline-dot--done',
current: 'timeline-dot--current',
pending: 'timeline-dot--pending',
}
return map[status] || ''
}
function previewImage(index: number) {
uni.previewImage({
urls: orderDetail.value.photos || [],
current: index,
})
}
function handleAssign() {
toast.info('指派人员功能开发中')
}
function handleTransfer() {
toast.info('转派功能开发中')
}
function handleComplete() {
toast.success('工单已完成')
}
function handleReopen() {
toast.info('重新开启功能开发中')
}
onMounted(() => {
if (props.id) {
console.log('加载工单详情:', props.id)
}
})
</script>
<style lang="scss" scoped>
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24rpx;
}
.info-item {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.info-label {
font-size: 24rpx;
color: #9ca3af;
font-weight: 600;
letter-spacing: 2rpx;
}
.info-value {
font-size: 26rpx;
color: #333;
}
.timeline {
padding-left: 8rpx;
}
.timeline-item {
position: relative;
padding-left: 60rpx;
padding-bottom: 32rpx;
&--last {
padding-bottom: 0;
}
&:not(&--last)::before {
content: '';
position: absolute;
left: 18rpx;
top: 48rpx;
width: 2rpx;
height: calc(100% - 48rpx);
background: #e5e7eb;
}
}
.timeline-dot {
position: absolute;
left: 0;
top: 4rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&--done {
background: #22c55e;
}
&--current {
background: #f97316;
animation: pulse 2s infinite;
}
&--pending {
background: #d1d5db;
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.4);
}
50% {
box-shadow: 0 0 0 12rpx rgba(249, 115, 22, 0);
}
}
.timeline-content {
padding-top: 4rpx;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<view class="yd-page-container">
<wd-navbar
title="工单池"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="handleBack"
/>
<!-- Tab 筛选 -->
<view class="mx-32rpx mt-24rpx">
<view class="tab-container">
<view
v-for="tab in tabs"
:key="tab.value"
class="tab-item"
:class="{ 'tab-item--active': activeTab === tab.value }"
@click="activeTab = tab.value"
>
{{ tab.label }}
</view>
</view>
</view>
<!-- 工单列表 -->
<view class="px-32rpx pt-24rpx">
<view
v-for="order in filteredOrders"
:key="order.id"
class="ai-card mb-24rpx p-36rpx"
@click="handleDetail(order)"
>
<view class="flex items-center justify-between">
<view class="text-32rpx text-[#1F2937] font-bold">
{{ order.title }}
</view>
<view
class="rounded-999px px-16rpx py-4rpx text-22rpx font-bold"
:class="getStatusClass(order.status)"
>
{{ getStatusText(order.status) }}
</view>
</view>
<view class="mt-12rpx text-24rpx text-[#9CA3AF]">
{{ order.area }} · {{ order.description }}
</view>
<view class="mt-16rpx flex items-center justify-between border-t-2rpx border-[#FFF7ED] pt-16rpx">
<view class="flex items-center">
<view
class="mr-8rpx h-12rpx w-12rpx rounded-full"
:class="getPriorityColor(order.priority)"
/>
<text class="text-22rpx text-[#9CA3AF] font-600">{{ order.priority }}</text>
</view>
<text class="text-22rpx text-[#D1D5DB]">{{ order.time }}</text>
</view>
</view>
</view>
<!-- 右下角悬浮新增按钮 -->
<view class="fab-btn" @click="handleCreate">
<view class="i-carbon-add text-24px text-white" />
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const activeTab = ref('all')
function handleBack() {
uni.navigateBack()
}
const tabs = [
{ label: '全部', value: 'all' },
{ label: '待派发', value: 'pending' },
{ label: '进行中', value: 'processing' },
]
interface WorkOrder {
id: number
title: string
area: string
description: string
status: 'pending' | 'processing' | 'done'
priority: string
time: string
}
const orders = ref<WorkOrder[]>([
{ id: 1, title: '大堂地面清洁', area: 'A栋大堂', description: '地面有明显污渍需要清理', status: 'pending', priority: 'P0-紧急', time: '10分钟前' },
{ id: 2, title: '电梯故障维修', area: 'B栋电梯', description: '3号电梯按钮失灵', status: 'processing', priority: 'P0-紧急', time: '30分钟前' },
{ id: 3, title: '绿化带修剪', area: 'C区花园', description: '绿化带杂草需要修剪', status: 'pending', priority: 'P1-高优', time: '1小时前' },
{ id: 4, title: '停车场灯光维修', area: 'B1停车场', description: '2层3区灯光不亮', status: 'done', priority: 'P2-普通', time: '2小时前' },
])
const filteredOrders = computed(() => {
if (activeTab.value === 'all')
return orders.value
if (activeTab.value === 'pending')
return orders.value.filter(o => o.status === 'pending')
return orders.value.filter(o => o.status === 'processing')
})
function handleCreate() {
uni.navigateTo({ url: '/pages/scan/work-order/create' })
}
function handleDetail(order: WorkOrder) {
uni.navigateTo({
url: `/pages/scan/work-order/detail?id=${order.id}`,
})
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待派发', processing: '进行中', done: '已完成' }
return map[status] || status
}
function getStatusClass(status: string) {
const map: Record<string, string> = {
pending: 'ai-badge--orange',
processing: 'bg-[#EFF6FF] text-[#3B82F6]',
done: 'ai-badge--green',
}
return map[status] || ''
}
function getPriorityColor(priority: string) {
if (priority.includes('P0'))
return 'bg-[#EF4444]'
if (priority.includes('P1'))
return 'bg-[#F97316]'
return 'bg-[#9CA3AF]'
}
</script>
<style lang="scss" scoped>
.fab-btn {
position: fixed;
right: 40rpx;
bottom: 120rpx;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fb923c, #f97316);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(249, 115, 22, 0.4);
z-index: 99;
&:active {
transform: scale(0.92);
}
}
.tab-container {
display: flex;
background: rgba(249, 115, 22, 0.06);
border-radius: 24rpx;
padding: 6rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 16rpx 0;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 600;
color: #9ca3af;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&--active {
background: #fff;
color: #f97316;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
}
</style>