feat: 实现申请报修页面

This commit is contained in:
czz
2026-05-08 14:59:31 +08:00
parent c42344e38a
commit 6d93440334
12 changed files with 1328 additions and 23 deletions

View File

@@ -9,12 +9,14 @@
- 通用 UI 优先拆成可复用组件,方便后续多页面共用
当前项目还新增了一版“睡眠报告演示页”用于展示宝宝睡眠评分、呼吸状态、HRV、异常统计和 AI 分析建议等完整报告结构。
同时也补上了一版“申请报修演示页”,用于展示设备报修表单、附件上传与提交成功后的详情页流程。
## 1. 目前已经包含什么
- `Taro + React + TypeScript` 项目骨架
- 首页设备绑定业务示例页面
- 睡眠报告演示页面
- 申请报修演示页面
- 小程序 `AppID` 配置
- `AGENTS.md` 协作规则文件
- 从开发到发布的中文说明
@@ -238,6 +240,26 @@ npm run dev:weapp
- 各板块尽量拆成了可复用组件,便于你继续改
- 分享功能当前还是前端占位提示,后续可再接真实能力
## 12.2 当前报修页做了什么
现在项目里已经把原来的“设备报修”占位页补成了一版可交互的“申请报修页”,主要包含:
- 使用小程序原生导航栏显示“申请报修”
- `体征监测设备 / AI摄像头` 两种设备类型切换
- 已绑定设备 ID 选择与设备参数自动带出
- 60 字以内的问题描述输入与实时字数统计
- 调用小程序 `chooseMedia` 选择图片或视频附件
- 图片最多 9 张、视频最多 1 个的前端限制
- 联系人和手机号填写
- 提交前必填校验与手机号格式校验
- mock 提交成功后跳转到“报修详情”占位页
说明:
- 当前报修页使用本地 mock 数据驱动
- 附件当前只做本地选择、预览和删除,不上传到真实服务器
- 历史记录按钮当前还是前端提示占位,后续可再接真实列表页
## 13. 你接下来最常做的开发动作
### 改公共主题色
@@ -276,6 +298,13 @@ npm run dev:weapp
- `src/components/report/` 目录下的组件文件
### 改报修页
编辑:
- [src/pages/repair/index.tsx](C:/Users/a/Documents/New%20project%203/src/pages/repair/index.tsx)
- [src/pages/repair-detail/index.tsx](C:/Users/a/Documents/New%20project%203/src/pages/repair-detail/index.tsx)
### 改全局样式
编辑:
@@ -388,6 +417,12 @@ npm run build:weapp
npm run test:report
```
如果你要验证报修页里的表单和附件规则纯逻辑,可以执行:
```bash
npm run test:repair
```
## 17. 建议你下一步怎么做
如果你是零基础,推荐按这个顺序继续:

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev:weapp": "taro build --type weapp --watch",
"build:weapp": "taro build --type weapp",
"test:report": "tsc -p tsconfig.report-tests.json && node scripts/tests/report-utils.test.cjs"
"test:report": "tsc -p tsconfig.report-tests.json && node scripts/tests/report-utils.test.cjs",
"test:repair": "tsc -p tsconfig.report-tests.json && node scripts/tests/repair-utils.test.cjs"
},
"dependencies": {
"@tarojs/components": "^4.0.0",

View File

@@ -0,0 +1,85 @@
const assert = require("node:assert/strict");
const {
appendRepairAttachments,
clampDescription,
isValidPhone,
buildDeviceMap,
normalizeChosenFiles,
validateRepairForm
} = require("../../tmp/report-tests/repair/repair-utils.js");
function run(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
throw error;
}
}
run("clampDescription trims to 60 chars", () => {
assert.equal(clampDescription("a".repeat(61)).length, 60);
});
run("isValidPhone accepts mainland mobile number", () => {
assert.equal(isValidPhone("13689569989"), true);
});
run("validateRepairForm rejects empty problem description", () => {
assert.equal(
validateRepairForm({
selectedDeviceId: "A1",
description: "",
contactName: "张小龙",
phone: "13689569989"
}),
"请填写问题描述"
);
});
run("buildDeviceMap groups devices by type", () => {
const map = buildDeviceMap([
{ id: "A1", type: "monitor", label: "设备A", params: "P1" },
{ id: "B1", type: "camera", label: "设备B", params: "P2" }
]);
assert.equal(map.monitor.length, 1);
assert.equal(map.camera[0].id, "B1");
});
run("normalizeChosenFiles tags images and videos", () => {
const files = normalizeChosenFiles([
{ tempFilePath: "x/a.png", fileType: "image", size: 10 },
{ tempFilePath: "x/b.mp4", fileType: "video", size: 20, thumbTempFilePath: "x/b.jpg", duration: 5 }
]);
assert.equal(files[0].kind, "image");
assert.equal(files[1].kind, "video");
assert.equal(files[1].thumbPath, "x/b.jpg");
});
run("appendRepairAttachments blocks second video", () => {
const result = appendRepairAttachments(
[{ id: "v1", kind: "video", path: "x/1.mp4" }],
[{ id: "v2", kind: "video", path: "x/2.mp4" }]
);
assert.equal(result.errorMessage, "最多上传1个视频");
assert.equal(result.attachments.length, 1);
});
run("appendRepairAttachments limits images to 9", () => {
const existing = Array.from({ length: 8 }, (_, index) => ({
id: `img-${index}`,
kind: "image",
path: `x/${index}.png`
}));
const result = appendRepairAttachments(existing, [
{ id: "img-8", kind: "image", path: "x/8.png" },
{ id: "img-9", kind: "image", path: "x/9.png" }
]);
assert.equal(result.errorMessage, "最多上传9张图片");
assert.equal(result.attachments.length, 9);
});

View File

@@ -9,6 +9,7 @@ export default defineAppConfig({
"pages/support/index",
"pages/devices/index",
"pages/repair/index",
"pages/repair-detail/index",
"pages/feedback/index",
"pages/videos/index",
"pages/follow-us/index"

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: "报修详情",
navigationBarBackgroundColor: "#0B1220",
navigationBarTextStyle: "white",
backgroundColor: "#0B1220"
});

View File

@@ -0,0 +1,156 @@
.repair-detail-page {
position: relative;
min-height: 100vh;
padding: 28rpx 24rpx 72rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #0b1220 0%, #121a2c 100%);
overflow: hidden;
}
.repair-detail-page__glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.repair-detail-page__glow--left {
top: -60rpx;
left: -100rpx;
width: 300rpx;
height: 300rpx;
background: radial-gradient(circle, rgba(53, 229, 179, 0.16) 0%, rgba(53, 229, 179, 0) 72%);
}
.repair-detail-page__glow--right {
top: 340rpx;
right: -120rpx;
width: 340rpx;
height: 340rpx;
background: radial-gradient(circle, rgba(59, 130, 246, 0.14) 0%, rgba(59, 130, 246, 0) 72%);
}
.repair-detail-page__hero,
.repair-detail-page__card,
.repair-detail-page__button {
position: relative;
z-index: 1;
}
.repair-detail-page__hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 18rpx 0 38rpx;
}
.repair-detail-page__success-ring {
position: relative;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(53, 229, 179, 0.3), rgba(32, 201, 191, 0.12));
box-shadow: inset 0 0 0 2rpx rgba(53, 229, 179, 0.28);
}
.repair-detail-page__success-check {
position: absolute;
top: 32rpx;
left: 42rpx;
width: 22rpx;
height: 40rpx;
border-right: 6rpx solid #35e5b3;
border-bottom: 6rpx solid #35e5b3;
transform: rotate(40deg);
}
.repair-detail-page__status {
margin-top: 22rpx;
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
}
.repair-detail-page__hint {
width: 560rpx;
margin-top: 16rpx;
color: rgba(255, 255, 255, 0.58);
font-size: 24rpx;
text-align: center;
line-height: 1.7;
}
.repair-detail-page__card {
margin-top: 22rpx;
padding: 28rpx;
border-radius: 28rpx;
background: rgba(34, 40, 57, 0.94);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
}
.repair-detail-page__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.repair-detail-page__row + .repair-detail-page__row {
margin-top: 20rpx;
}
.repair-detail-page__label,
.repair-detail-page__meta-label {
color: rgba(255, 255, 255, 0.44);
font-size: 24rpx;
}
.repair-detail-page__value,
.repair-detail-page__meta-value {
color: rgba(255, 255, 255, 0.9);
font-size: 24rpx;
text-align: right;
word-break: break-all;
}
.repair-detail-page__value--accent {
color: #35e5b3;
}
.repair-detail-page__section-title {
display: block;
color: rgba(255, 255, 255, 0.92);
font-size: 26rpx;
}
.repair-detail-page__description {
display: block;
margin-top: 18rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
line-height: 1.8;
}
.repair-detail-page__meta-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.05);
}
.repair-detail-page__button {
height: 96rpx;
margin-top: 44rpx;
border: none;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
line-height: 96rpx;
}
.repair-detail-page__button::after {
border: none;
}

View File

@@ -0,0 +1,99 @@
import { Button, Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import {
REPAIR_DEVICE_TYPE_LABELS,
REPAIR_DRAFT_STORAGE_KEY,
type RepairSubmissionSnapshot
} from "../repair/repair-utils";
import "./index.scss";
function getAttachmentSummary(detail?: RepairSubmissionSnapshot) {
if (!detail) {
return "0 个附件";
}
const imageCount = detail.attachments.filter((item) => item.kind === "image").length;
const videoCount = detail.attachments.filter((item) => item.kind === "video").length;
if (!imageCount && !videoCount) {
return "0 个附件";
}
return `${imageCount} 张图片 / ${videoCount} 个视频`;
}
export default function RepairDetailPage() {
const detail = Taro.getStorageSync(REPAIR_DRAFT_STORAGE_KEY) as RepairSubmissionSnapshot | undefined;
return (
<View className="repair-detail-page">
<View className="repair-detail-page__glow repair-detail-page__glow--left" />
<View className="repair-detail-page__glow repair-detail-page__glow--right" />
<View className="repair-detail-page__hero">
<View className="repair-detail-page__success-ring">
<View className="repair-detail-page__success-check" />
</View>
<Text className="repair-detail-page__status"></Text>
<Text className="repair-detail-page__hint"></Text>
</View>
<View className="repair-detail-page__card">
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.ticketNo || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.createdAt || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value repair-detail-page__value--accent">{detail?.statusLabel || "已提交,待审核"}</Text>
</View>
</View>
<View className="repair-detail-page__card">
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">
{detail ? REPAIR_DEVICE_TYPE_LABELS[detail.deviceType] : "--"}
</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label">ID</Text>
<Text className="repair-detail-page__value">{detail?.deviceId || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.deviceParams || "--"}</Text>
</View>
</View>
<View className="repair-detail-page__card">
<Text className="repair-detail-page__section-title"></Text>
<Text className="repair-detail-page__description">{detail?.description || "暂无描述"}</Text>
<View className="repair-detail-page__meta-row">
<Text className="repair-detail-page__meta-label"></Text>
<Text className="repair-detail-page__meta-value">{getAttachmentSummary(detail)}</Text>
</View>
</View>
<View className="repair-detail-page__card">
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.contactName || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.phone || "--"}</Text>
</View>
</View>
<Button className="repair-detail-page__button" onClick={() => Taro.navigateBack()}>
</Button>
</View>
);
}

View File

@@ -1,3 +1,6 @@
export default definePageConfig({
navigationBarTitleText: "设备报修"
navigationBarTitleText: "申请报修",
navigationBarBackgroundColor: "#0B1220",
navigationBarTextStyle: "white",
backgroundColor: "#0B1220"
});

View File

@@ -1,3 +1,419 @@
.repair-page {
position: relative;
min-height: 100vh;
padding: 28rpx 24rpx 72rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #0b1220 0%, #121a2c 100%);
overflow: hidden;
}
.repair-page__glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.repair-page__glow--left {
top: 60rpx;
left: -120rpx;
width: 320rpx;
height: 320rpx;
background: radial-gradient(circle, rgba(53, 229, 179, 0.18) 0%, rgba(53, 229, 179, 0) 72%);
}
.repair-page__glow--right {
top: 260rpx;
right: -120rpx;
width: 340rpx;
height: 340rpx;
background: radial-gradient(circle, rgba(76, 118, 214, 0.14) 0%, rgba(76, 118, 214, 0) 72%);
}
.repair-page__toolbar,
.repair-page__tabs,
.repair-page__card,
.repair-page__add-card,
.repair-page__submit {
position: relative;
z-index: 1;
}
.repair-page__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.repair-page__toolbar-spacer {
width: 1rpx;
height: 1rpx;
}
.repair-page__history {
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 0 8rpx 12rpx;
}
.repair-page__clock {
position: relative;
width: 34rpx;
height: 34rpx;
border: 2rpx solid rgba(255, 255, 255, 0.8);
border-radius: 50%;
}
.repair-page__clock-hand {
position: absolute;
left: 50%;
bottom: 50%;
width: 2rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 999rpx;
transform-origin: bottom center;
}
.repair-page__clock-hand--hour {
height: 9rpx;
transform: translateX(-50%) rotate(18deg);
}
.repair-page__clock-hand--minute {
height: 12rpx;
transform: translateX(-50%) rotate(112deg);
}
.repair-page__history-text {
color: rgba(255, 255, 255, 0.76);
font-size: 22rpx;
}
.repair-page__tabs {
display: flex;
gap: 24rpx;
margin-bottom: 24rpx;
}
.repair-page__tab {
min-width: 218rpx;
padding: 24rpx 28rpx;
border-radius: 999rpx;
background: rgba(34, 40, 57, 0.88);
text-align: center;
box-sizing: border-box;
}
.repair-page__tab--active {
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
box-shadow: 0 18rpx 38rpx rgba(24, 178, 156, 0.24);
}
.repair-page__tab-text {
color: rgba(255, 255, 255, 0.72);
font-size: 26rpx;
}
.repair-page__tab-text--active {
color: #ffffff;
font-weight: 600;
}
.repair-page__card,
.repair-page__add-card {
border-radius: 28rpx;
background: rgba(34, 40, 57, 0.94);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
}
.repair-page__card {
padding: 28rpx;
}
.repair-page__card--contact {
margin-top: 28rpx;
}
.repair-page__field-row {
display: flex;
align-items: center;
gap: 22rpx;
}
.repair-page__field-row + .repair-page__field-row {
margin-top: 26rpx;
}
.repair-page__field-label {
width: 124rpx;
flex-shrink: 0;
color: rgba(255, 255, 255, 0.9);
font-size: 26rpx;
}
.repair-page__field-box {
display: flex;
align-items: center;
width: 0;
min-height: 84rpx;
flex: 1;
padding: 0 24rpx;
border-radius: 22rpx;
background: rgba(17, 24, 39, 0.96);
box-sizing: border-box;
}
.repair-page__field-box--select {
justify-content: space-between;
}
.repair-page__field-box--disabled {
opacity: 0.8;
}
.repair-page__field-value {
color: #f7fbff;
font-size: 26rpx;
}
.repair-page__field-value--muted {
color: rgba(255, 255, 255, 0.42);
}
.repair-page__chevron {
width: 16rpx;
height: 16rpx;
border-right: 2rpx solid rgba(255, 255, 255, 0.68);
border-bottom: 2rpx solid rgba(255, 255, 255, 0.68);
transform: rotate(45deg);
}
.repair-page__textarea-wrap {
margin-top: 28rpx;
padding: 24rpx 24rpx 58rpx;
border-radius: 24rpx;
background: rgba(17, 24, 39, 0.96);
}
.repair-page__textarea {
width: 100%;
height: 180rpx;
color: #f5f7fb;
font-size: 28rpx;
line-height: 1.6;
}
.repair-page__placeholder {
color: rgba(255, 255, 255, 0.32);
}
.repair-page__textarea-count {
position: absolute;
right: 52rpx;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.34);
font-size: 22rpx;
}
.repair-page__upload-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 340rpx;
margin-top: 26rpx;
padding: 32rpx;
border-radius: 28rpx;
background: rgba(17, 24, 39, 0.92);
}
.repair-page__camera {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 28rpx;
}
.repair-page__camera-top {
width: 26rpx;
height: 10rpx;
margin-right: 52rpx;
border-radius: 10rpx 10rpx 0 0;
background: rgba(255, 255, 255, 0.4);
}
.repair-page__camera-body {
display: flex;
align-items: center;
justify-content: center;
width: 86rpx;
height: 58rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.4);
}
.repair-page__camera-lens {
width: 28rpx;
height: 28rpx;
border: 6rpx solid rgba(17, 24, 39, 0.9);
border-radius: 50%;
}
.repair-page__upload-title {
color: rgba(255, 255, 255, 0.56);
font-size: 28rpx;
text-align: center;
line-height: 1.5;
}
.repair-page__upload-subtitle {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.32);
font-size: 22rpx;
}
.repair-page__attachment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 22rpx;
}
.repair-page__attachment {
position: relative;
overflow: hidden;
border-radius: 22rpx;
background: rgba(12, 19, 31, 0.86);
}
.repair-page__attachment-image,
.repair-page__video-cover {
display: block;
width: 100%;
height: 180rpx;
}
.repair-page__video-cover {
position: relative;
background: linear-gradient(135deg, rgba(32, 201, 191, 0.18), rgba(67, 100, 247, 0.18));
}
.repair-page__video-badge {
position: absolute;
left: 18rpx;
bottom: 18rpx;
padding: 6rpx 12rpx;
border-radius: 999rpx;
background: rgba(11, 18, 32, 0.74);
color: #ffffff;
font-size: 20rpx;
letter-spacing: 1rpx;
}
.repair-page__attachment-meta {
padding: 16rpx 18rpx 18rpx;
}
.repair-page__attachment-name,
.repair-page__attachment-size {
display: block;
}
.repair-page__attachment-name {
color: rgba(255, 255, 255, 0.88);
font-size: 22rpx;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.repair-page__attachment-size {
margin-top: 4rpx;
color: rgba(255, 255, 255, 0.34);
font-size: 20rpx;
}
.repair-page__attachment-delete {
position: absolute;
top: 12rpx;
right: 12rpx;
display: flex;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
border-radius: 50%;
background: rgba(11, 18, 32, 0.75);
}
.repair-page__attachment-delete-text {
color: #ffffff;
font-size: 28rpx;
line-height: 1;
}
.repair-page__add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 124rpx;
margin-top: 28rpx;
}
.repair-page__plus {
position: relative;
width: 54rpx;
height: 54rpx;
border-radius: 50%;
background: rgba(18, 201, 191, 0.18);
}
.repair-page__plus-line {
position: absolute;
top: 50%;
left: 50%;
background: #22d3c5;
transform: translate(-50%, -50%);
}
.repair-page__plus-line--horizontal {
width: 24rpx;
height: 2rpx;
}
.repair-page__plus-line--vertical {
width: 2rpx;
height: 24rpx;
}
.repair-page__add-text {
color: rgba(255, 255, 255, 0.38);
font-size: 22rpx;
}
.repair-page__input {
width: 100%;
color: #f7fbff;
font-size: 26rpx;
}
.repair-page__submit {
height: 96rpx;
margin-top: 44rpx;
border: none;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
line-height: 96rpx;
box-shadow: 0 24rpx 42rpx rgba(24, 178, 156, 0.22);
}
.repair-page__submit::after {
border: none;
}

View File

@@ -1,28 +1,387 @@
import SecondaryPage, { type SecondaryPageSection } from "../../components/secondary-page";
import { Button, Image, Input, Text, Textarea, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import { useMemo, useState } from "react";
import {
REPAIR_DESCRIPTION_LIMIT,
REPAIR_DEVICE_TYPE_LABELS,
REPAIR_DRAFT_STORAGE_KEY,
type RepairAttachment,
type RepairDeviceOption,
type RepairDeviceType,
type RepairSubmissionSnapshot,
appendRepairAttachments,
buildDeviceMap,
clampDescription,
normalizeChosenFiles,
validateRepairForm
} from "./repair-utils";
import "./index.scss";
const sections: SecondaryPageSection[] = [
{
title: "报修申请",
items: [
{ label: "提交报修申请" },
{ label: "上传故障图片", value: "待接入上传能力" },
{ label: "填写设备问题", value: "待接入表单" }
]
},
{
title: "售后进度",
items: [{ label: "维修进度查询", value: "后续接接口" }]
}
const repairDevices: RepairDeviceOption[] = [
{ id: "A9876456546", type: "monitor", label: "A9876456546", params: "3AW1 / 654616313" },
{ id: "A3648201488", type: "monitor", label: "A3648201488", params: "5BZ2 / 882730114" },
{ id: "C7842037781", type: "camera", label: "C7842037781", params: "AI-CAM / 220184900" },
{ id: "C7842037782", type: "camera", label: "C7842037782", params: "AI-CAM / 220184901" }
];
const groupedDevices = buildDeviceMap(repairDevices);
const deviceTypeOrder: RepairDeviceType[] = ["monitor", "camera"];
function delay(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function formatAttachmentSize(size?: number) {
if (!size) {
return "";
}
if (size >= 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)}MB`;
}
return `${Math.max(size / 1024, 0.1).toFixed(1)}KB`;
}
function createTicketNo() {
return `BX${Date.now().toString().slice(-8)}`;
}
export default function RepairPage() {
const [deviceType, setDeviceType] = useState<RepairDeviceType>("monitor");
const [selectedDeviceId, setSelectedDeviceId] = useState(groupedDevices.monitor[0]?.id || "");
const [description, setDescription] = useState("");
const [attachments, setAttachments] = useState<RepairAttachment[]>([]);
const [contactName, setContactName] = useState("张小龙");
const [phone, setPhone] = useState("13689569989");
const [submitting, setSubmitting] = useState(false);
const currentDevices = groupedDevices[deviceType];
const selectedDevice =
currentDevices.find((item) => item.id === selectedDeviceId) || currentDevices[0] || repairDevices[0];
const imageCount = attachments.filter((item) => item.kind === "image").length;
const videoCount = attachments.filter((item) => item.kind === "video").length;
const uploadHint = useMemo(() => {
if (deviceType === "camera") {
return "点击上传AI摄像头故障照片或视频";
}
return "点击上传体征设备故障照片或视频";
}, [deviceType]);
const showToast = (title: string) => {
Taro.showToast({
title,
icon: "none"
});
};
const handleTypeChange = (nextType: RepairDeviceType) => {
if (nextType === deviceType) {
return;
}
const nextDevices = groupedDevices[nextType];
setDeviceType(nextType);
setSelectedDeviceId(nextDevices[0]?.id || "");
};
const handleSelectDevice = async () => {
if (!currentDevices.length) {
showToast("当前暂无可选设备");
return;
}
try {
const result = await Taro.showActionSheet({
itemList: currentDevices.map((item) => item.label)
});
const nextDevice = currentDevices[result.tapIndex];
if (nextDevice) {
setSelectedDeviceId(nextDevice.id);
}
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (!message.includes("cancel")) {
showToast("设备选择失败");
}
}
};
const handleChooseMedia = async () => {
try {
const result = await Taro.chooseMedia({
count: 9,
mediaType: ["image", "video"],
sourceType: ["album", "camera"]
});
const chosen = normalizeChosenFiles((result.tempFiles || []) as never[]);
if (!chosen.length) {
return;
}
const merged = appendRepairAttachments(attachments, chosen);
setAttachments(merged.attachments);
if (merged.errorMessage) {
showToast(merged.errorMessage);
}
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message.includes("cancel")) {
return;
}
showToast("上传失败,请重试");
}
};
const handlePreviewAttachment = (attachment: RepairAttachment) => {
if (attachment.kind === "image") {
Taro.previewImage({
current: attachment.path,
urls: attachments.filter((item) => item.kind === "image").map((item) => item.path)
});
return;
}
const previewMedia = (Taro as unknown as {
previewMedia?: (options: {
current?: number;
sources: Array<{ url: string; type: "image" | "video"; poster?: string }>;
}) => Promise<unknown>;
}).previewMedia;
if (typeof previewMedia === "function") {
void previewMedia({
current: 0,
sources: [
{
url: attachment.path,
type: "video",
poster: attachment.thumbPath
}
]
});
return;
}
showToast("当前环境暂不支持视频预览");
};
const handleDeleteAttachment = (attachmentId: string, event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
setAttachments((prev) => prev.filter((item) => item.id !== attachmentId));
};
const handleSubmit = async () => {
const errorMessage = validateRepairForm({
selectedDeviceId,
description,
contactName,
phone
});
if (errorMessage) {
showToast(errorMessage);
return;
}
if (!selectedDevice) {
showToast("请选择设备");
return;
}
const payload: RepairSubmissionSnapshot = {
ticketNo: createTicketNo(),
deviceType,
deviceTypeLabel: REPAIR_DEVICE_TYPE_LABELS[deviceType],
deviceId: selectedDevice.id,
deviceParams: selectedDevice.params,
description: description.trim(),
attachments,
contactName: contactName.trim(),
phone: phone.trim(),
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
statusLabel: "已提交,待审核"
};
setSubmitting(true);
try {
await delay(800);
Taro.setStorageSync(REPAIR_DRAFT_STORAGE_KEY, payload);
await Taro.showToast({
title: "提交成功",
icon: "success"
});
await Taro.navigateTo({ url: "/pages/repair-detail/index" });
} catch {
showToast("提交失败,请稍后重试");
} finally {
setSubmitting(false);
}
};
return (
<SecondaryPage
eyebrow="REPAIR"
title="设备报修"
description="这个页面先把报修流程的主要节点放齐,后面接图片上传和工单接口时改动会比较小。"
sections={sections}
/>
<View className="repair-page">
<View className="repair-page__glow repair-page__glow--left" />
<View className="repair-page__glow repair-page__glow--right" />
<View className="repair-page__toolbar">
<View className="repair-page__toolbar-spacer" />
<View className="repair-page__history" onClick={() => showToast("历史记录功能待开放")}>
<View className="repair-page__clock">
<View className="repair-page__clock-hand repair-page__clock-hand--hour" />
<View className="repair-page__clock-hand repair-page__clock-hand--minute" />
</View>
<Text className="repair-page__history-text"></Text>
</View>
</View>
<View className="repair-page__tabs">
{deviceTypeOrder.map((item) => (
<View
className={`repair-page__tab ${item === deviceType ? "repair-page__tab--active" : ""}`}
key={item}
onClick={() => handleTypeChange(item)}
>
<Text className={`repair-page__tab-text ${item === deviceType ? "repair-page__tab-text--active" : ""}`}>
{REPAIR_DEVICE_TYPE_LABELS[item]}
</Text>
</View>
))}
</View>
<View className="repair-page__card">
<View className="repair-page__field-row">
<Text className="repair-page__field-label">ID</Text>
<View className="repair-page__field-box repair-page__field-box--select" onClick={handleSelectDevice}>
<Text className="repair-page__field-value">{selectedDevice?.label || "请选择设备"}</Text>
<View className="repair-page__chevron" />
</View>
</View>
<View className="repair-page__field-row">
<Text className="repair-page__field-label"></Text>
<View className="repair-page__field-box repair-page__field-box--disabled">
<Text className="repair-page__field-value repair-page__field-value--muted">{selectedDevice?.params || "--"}</Text>
</View>
</View>
<View className="repair-page__textarea-wrap">
<Textarea
className="repair-page__textarea"
maxlength={REPAIR_DESCRIPTION_LIMIT}
placeholder="问题描述60个字以内"
placeholderClass="repair-page__placeholder"
value={description}
onInput={(event) => setDescription(clampDescription(event.detail.value))}
/>
<Text className="repair-page__textarea-count">
{description.length}/{REPAIR_DESCRIPTION_LIMIT}
</Text>
</View>
<View className="repair-page__upload-panel" onClick={handleChooseMedia}>
<View className="repair-page__camera">
<View className="repair-page__camera-top" />
<View className="repair-page__camera-body">
<View className="repair-page__camera-lens" />
</View>
</View>
<Text className="repair-page__upload-title">{uploadHint}</Text>
<Text className="repair-page__upload-subtitle">
{imageCount}/9 {videoCount}/1
</Text>
</View>
{attachments.length ? (
<View className="repair-page__attachment-grid">
{attachments.map((attachment) => (
<View
className={`repair-page__attachment ${attachment.kind === "video" ? "repair-page__attachment--video" : ""}`}
key={attachment.id}
onClick={() => handlePreviewAttachment(attachment)}
>
{attachment.kind === "image" ? (
<Image className="repair-page__attachment-image" mode="aspectFill" src={attachment.path} />
) : (
<View className="repair-page__video-cover">
{attachment.thumbPath ? (
<Image className="repair-page__attachment-image" mode="aspectFill" src={attachment.thumbPath} />
) : null}
<View className="repair-page__video-badge">VIDEO</View>
</View>
)}
<View className="repair-page__attachment-meta">
<Text className="repair-page__attachment-name">{attachment.name || "附件"}</Text>
<Text className="repair-page__attachment-size">
{attachment.kind === "video" ? `视频 ${formatAttachmentSize(attachment.size)}` : formatAttachmentSize(attachment.size)}
</Text>
</View>
<View className="repair-page__attachment-delete" onClick={(event) => handleDeleteAttachment(attachment.id, event)}>
<Text className="repair-page__attachment-delete-text">×</Text>
</View>
</View>
))}
</View>
) : null}
</View>
<View className="repair-page__add-card" onClick={handleChooseMedia}>
<View className="repair-page__plus">
<View className="repair-page__plus-line repair-page__plus-line--horizontal" />
<View className="repair-page__plus-line repair-page__plus-line--vertical" />
</View>
<Text className="repair-page__add-text"></Text>
</View>
<View className="repair-page__card repair-page__card--contact">
<View className="repair-page__field-row">
<Text className="repair-page__field-label"></Text>
<View className="repair-page__field-box">
<Input
className="repair-page__input"
maxlength={20}
placeholder="请输入联系人"
placeholderClass="repair-page__placeholder"
value={contactName}
onInput={(event) => setContactName(event.detail.value)}
/>
</View>
</View>
<View className="repair-page__field-row">
<Text className="repair-page__field-label"></Text>
<View className="repair-page__field-box">
<Input
className="repair-page__input"
maxlength={11}
placeholder="请输入手机号"
placeholderClass="repair-page__placeholder"
type="number"
value={phone}
onInput={(event) => setPhone(event.detail.value)}
/>
</View>
</View>
</View>
<Button className="repair-page__submit" disabled={submitting} loading={submitting} onClick={handleSubmit}>
</Button>
</View>
);
}

View File

@@ -0,0 +1,144 @@
export type RepairDeviceType = "monitor" | "camera";
export type RepairDeviceOption = {
id: string;
type: RepairDeviceType;
label: string;
params: string;
};
export type RepairAttachment = {
id: string;
kind: "image" | "video";
path: string;
size?: number;
duration?: number;
thumbPath?: string;
name?: string;
};
export type RepairFormInput = {
selectedDeviceId: string;
description: string;
contactName: string;
phone: string;
};
export type RepairSubmissionSnapshot = {
ticketNo: string;
deviceType: RepairDeviceType;
deviceTypeLabel: string;
deviceId: string;
deviceParams: string;
description: string;
attachments: RepairAttachment[];
contactName: string;
phone: string;
createdAt: string;
statusLabel: string;
};
type ChosenMediaFile = {
tempFilePath: string;
fileType?: "image" | "video";
size?: number;
duration?: number;
thumbTempFilePath?: string;
};
export const REPAIR_DESCRIPTION_LIMIT = 60;
export const REPAIR_DRAFT_STORAGE_KEY = "repair-request-draft";
export const REPAIR_DEVICE_TYPE_LABELS: Record<RepairDeviceType, string> = {
monitor: "体征监测设备",
camera: "AI摄像头"
};
export function clampDescription(value: string, limit = REPAIR_DESCRIPTION_LIMIT) {
return value.slice(0, limit);
}
export function isValidPhone(value: string) {
return /^1[3-9]\d{9}$/.test(value);
}
export function buildDeviceMap(devices: RepairDeviceOption[]) {
return devices.reduce<Record<RepairDeviceType, RepairDeviceOption[]>>(
(result, item) => {
result[item.type].push(item);
return result;
},
{
monitor: [],
camera: []
}
);
}
export function normalizeChosenFiles(files: ChosenMediaFile[]): RepairAttachment[] {
return files.map((item, index) => ({
id: `${item.tempFilePath}-${index}`,
kind: item.fileType === "video" ? "video" : "image",
path: item.tempFilePath,
size: item.size,
duration: item.duration,
thumbPath: item.thumbTempFilePath,
name: item.tempFilePath.split("/").pop() || item.tempFilePath
}));
}
export function appendRepairAttachments(existing: RepairAttachment[], incoming: RepairAttachment[]) {
const attachments = [...existing];
let errorMessage = "";
incoming.forEach((item) => {
if (item.kind === "video") {
const hasVideo = attachments.some((attachment) => attachment.kind === "video");
if (hasVideo) {
errorMessage = errorMessage || "最多上传1个视频";
return;
}
attachments.push(item);
return;
}
const imageCount = attachments.filter((attachment) => attachment.kind === "image").length;
if (imageCount >= 9) {
errorMessage = errorMessage || "最多上传9张图片";
return;
}
attachments.push(item);
});
return {
attachments,
errorMessage
};
}
export function validateRepairForm(input: RepairFormInput) {
if (!input.selectedDeviceId) {
return "请选择设备";
}
if (!input.description.trim()) {
return "请填写问题描述";
}
if (!input.contactName.trim()) {
return "请填写联系人";
}
if (!input.phone.trim()) {
return "请填写手机号";
}
if (!isValidPhone(input.phone)) {
return "请输入正确的手机号";
}
return "";
}

View File

@@ -8,5 +8,5 @@
"declaration": false,
"types": []
},
"include": ["src/pages/report/report-utils.ts"]
"include": ["src/pages/report/report-utils.ts", "src/pages/repair/repair-utils.ts"]
}