feat: 新增 IoT API 接口、扫码页面和启动页
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
65
src/api/iot/work-order/index.ts
Normal file
65
src/api/iot/work-order/index.ts
Normal 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}`)
|
||||
}
|
||||
160
src/pages-core/splash/index.vue
Normal file
160
src/pages-core/splash/index.vue
Normal 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
141
src/pages/scan/index.vue
Normal 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>
|
||||
206
src/pages/scan/inspection/index.vue
Normal file
206
src/pages/scan/inspection/index.vue
Normal 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>
|
||||
145
src/pages/scan/staff/index.vue
Normal file
145
src/pages/scan/staff/index.vue
Normal 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>
|
||||
207
src/pages/scan/work-order/components/progress-timeline.vue
Normal file
207
src/pages/scan/work-order/components/progress-timeline.vue
Normal 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>
|
||||
205
src/pages/scan/work-order/create.vue
Normal file
205
src/pages/scan/work-order/create.vue
Normal 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>
|
||||
391
src/pages/scan/work-order/detail.vue
Normal file
391
src/pages/scan/work-order/detail.vue
Normal 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>
|
||||
192
src/pages/scan/work-order/index.vue
Normal file
192
src/pages/scan/work-order/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user