feat: 实现申请报修页面
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user