Add multi-session auth and changelog tracking
这个提交包含在:
@@ -23,7 +23,16 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ACTION_LABELS as RECOGNIZED_ACTION_LABELS,
|
||||
type ActionObservation,
|
||||
type ActionType,
|
||||
type TrackingState,
|
||||
recognizeActionFrame,
|
||||
stabilizeActionFrame,
|
||||
} from "@/lib/actionRecognition";
|
||||
import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import {
|
||||
Activity,
|
||||
Camera,
|
||||
@@ -68,6 +77,8 @@ const SEGMENT_LENGTH_MS = 60_000;
|
||||
const MOTION_SAMPLE_MS = 1_500;
|
||||
const MOTION_THRESHOLD = 18;
|
||||
const MOTION_COOLDOWN_MS = 8_000;
|
||||
const ACTION_SAMPLE_MS = 2_500;
|
||||
const INVALID_RECORDING_WINDOW_MS = 60_000;
|
||||
|
||||
const QUALITY_PRESETS = {
|
||||
economy: {
|
||||
@@ -151,6 +162,30 @@ function formatFileSize(bytes: number) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(bytes > 20 * 1024 * 1024 ? 1 : 2)} MB`;
|
||||
}
|
||||
|
||||
function createActionSummary(): Record<ActionType, number> {
|
||||
return {
|
||||
forehand: 0,
|
||||
backhand: 0,
|
||||
serve: 0,
|
||||
volley: 0,
|
||||
overhead: 0,
|
||||
slice: 0,
|
||||
lob: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeActions(actionSummary: Record<ActionType, number>) {
|
||||
return Object.entries(actionSummary)
|
||||
.filter(([actionType, count]) => actionType !== "unknown" && count > 0)
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.map(([actionType, count]) => ({
|
||||
actionType: actionType as ActionType,
|
||||
label: RECOGNIZED_ACTION_LABELS[actionType as ActionType] || actionType,
|
||||
count,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function Recorder() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
@@ -170,11 +205,22 @@ export default function Recorder() {
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const peerRef = useRef<RTCPeerConnection | null>(null);
|
||||
const actionPoseRef = useRef<any>(null);
|
||||
const currentSegmentStartedAtRef = useRef<number>(0);
|
||||
const recordingStartedAtRef = useRef<number>(0);
|
||||
const segmentSequenceRef = useRef(0);
|
||||
const motionFrameRef = useRef<Uint8ClampedArray | null>(null);
|
||||
const lastMotionMarkerAtRef = useRef(0);
|
||||
const actionTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const actionFrameInFlightRef = useRef(false);
|
||||
const actionTrackingRef = useRef<TrackingState>({});
|
||||
const actionHistoryRef = useRef<ActionObservation[]>([]);
|
||||
const actionSummaryRef = useRef<Record<ActionType, number>>(createActionSummary());
|
||||
const lastRecognizedActionAtRef = useRef<number>(0);
|
||||
const lastActionMarkerAtRef = useRef<number>(0);
|
||||
const latestRecognizedActionRef = useRef<ActionType>("unknown");
|
||||
const validityOverrideRef = useRef<"valid" | "invalid" | null>(null);
|
||||
const invalidAutoMarkedRef = useRef(false);
|
||||
const pendingUploadsRef = useRef<PendingSegment[]>([]);
|
||||
const uploadInFlightRef = useRef(false);
|
||||
const currentSessionRef = useRef<MediaSession | null>(null);
|
||||
@@ -202,13 +248,17 @@ export default function Recorder() {
|
||||
const [uploadedSegments, setUploadedSegments] = useState(0);
|
||||
const [uploadBytes, setUploadBytes] = useState(0);
|
||||
const [cameraError, setCameraError] = useState("");
|
||||
const [title, setTitle] = useState(() => `训练录制 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`);
|
||||
const [title, setTitle] = useState(() => `训练录制 ${formatDateTimeShanghai(new Date(), { year: undefined, second: undefined })}`);
|
||||
const [mediaSession, setMediaSession] = useState<MediaSession | null>(null);
|
||||
const [markers, setMarkers] = useState<MediaMarker[]>([]);
|
||||
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
|
||||
const [immersivePreview, setImmersivePreview] = useState(false);
|
||||
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(null);
|
||||
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
|
||||
const [actionSummary, setActionSummary] = useState<Record<ActionType, number>>(() => createActionSummary());
|
||||
const [currentDetectedAction, setCurrentDetectedAction] = useState<ActionType>("unknown");
|
||||
const [recordingValidity, setRecordingValidity] = useState<"pending" | "valid" | "invalid">("pending");
|
||||
const [recordingValidityReason, setRecordingValidityReason] = useState("");
|
||||
|
||||
const mobile = useMemo(() => isMobileDevice(), []);
|
||||
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
||||
@@ -224,6 +274,7 @@ export default function Recorder() {
|
||||
mediaSession?.archiveStatus === "queued" ||
|
||||
mediaSession?.archiveStatus === "processing";
|
||||
const canLeaveRecorderPage = !uploadStillDraining && (archiveRunning || mode === "archived");
|
||||
const recognizedActionItems = useMemo(() => summarizeActions(actionSummary), [actionSummary]);
|
||||
|
||||
const syncSessionState = useCallback((session: MediaSession | null) => {
|
||||
currentSessionRef.current = session;
|
||||
@@ -273,9 +324,11 @@ export default function Recorder() {
|
||||
if (segmentTickerRef.current) clearInterval(segmentTickerRef.current);
|
||||
if (timerTickerRef.current) clearInterval(timerTickerRef.current);
|
||||
if (motionTickerRef.current) clearInterval(motionTickerRef.current);
|
||||
if (actionTickerRef.current) clearInterval(actionTickerRef.current);
|
||||
segmentTickerRef.current = null;
|
||||
timerTickerRef.current = null;
|
||||
motionTickerRef.current = null;
|
||||
actionTickerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const closePeer = useCallback(() => {
|
||||
@@ -479,7 +532,11 @@ export default function Recorder() {
|
||||
await stopped;
|
||||
}, [stopTickers]);
|
||||
|
||||
const createManualMarker = useCallback(async (type: "manual" | "motion", label: string, confidence?: number) => {
|
||||
const createManualMarker = useCallback(async (
|
||||
type: "manual" | "motion" | "action_detected" | "invalid_auto" | "invalid_manual" | "valid_manual",
|
||||
label: string,
|
||||
confidence?: number,
|
||||
) => {
|
||||
const sessionId = currentSessionRef.current?.id;
|
||||
if (!sessionId) return;
|
||||
|
||||
@@ -508,6 +565,132 @@ export default function Recorder() {
|
||||
}
|
||||
}, [syncSessionState]);
|
||||
|
||||
const setValidityState = useCallback((
|
||||
nextStatus: "pending" | "valid" | "invalid",
|
||||
reason: string,
|
||||
override: "valid" | "invalid" | null = validityOverrideRef.current,
|
||||
) => {
|
||||
validityOverrideRef.current = override;
|
||||
setRecordingValidity(nextStatus);
|
||||
setRecordingValidityReason(reason);
|
||||
}, []);
|
||||
|
||||
const startActionSampling = useCallback(async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const liveVideo = liveVideoRef.current;
|
||||
if (!liveVideo) return;
|
||||
|
||||
if (!actionPoseRef.current) {
|
||||
const testFactory = (
|
||||
window as typeof window & {
|
||||
__TEST_MEDIAPIPE_FACTORY__?: () => Promise<{ Pose: any }>;
|
||||
}
|
||||
).__TEST_MEDIAPIPE_FACTORY__;
|
||||
const { Pose } = testFactory ? await testFactory() : await import("@mediapipe/pose");
|
||||
const pose = new Pose({
|
||||
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`,
|
||||
});
|
||||
pose.setOptions({
|
||||
modelComplexity: 0,
|
||||
smoothLandmarks: true,
|
||||
enableSegmentation: false,
|
||||
minDetectionConfidence: 0.5,
|
||||
minTrackingConfidence: 0.5,
|
||||
});
|
||||
pose.onResults((results: { poseLandmarks?: Array<{ x: number; y: number; visibility?: number }> }) => {
|
||||
if (!results.poseLandmarks) {
|
||||
return;
|
||||
}
|
||||
|
||||
const analyzed = stabilizeActionFrame(
|
||||
recognizeActionFrame(results.poseLandmarks, actionTrackingRef.current, performance.now()),
|
||||
actionHistoryRef.current,
|
||||
);
|
||||
setCurrentDetectedAction(analyzed.action);
|
||||
|
||||
if (analyzed.action === "unknown" || analyzed.confidence < 0.55) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestRecognizedActionRef.current = analyzed.action;
|
||||
lastRecognizedActionAtRef.current = Date.now();
|
||||
actionSummaryRef.current = {
|
||||
...actionSummaryRef.current,
|
||||
[analyzed.action]: (actionSummaryRef.current[analyzed.action] || 0) + 1,
|
||||
};
|
||||
setActionSummary({ ...actionSummaryRef.current });
|
||||
|
||||
if (validityOverrideRef.current !== "invalid") {
|
||||
setValidityState("valid", `已识别到 ${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, validityOverrideRef.current);
|
||||
}
|
||||
|
||||
if (Date.now() - lastActionMarkerAtRef.current >= 15_000) {
|
||||
lastActionMarkerAtRef.current = Date.now();
|
||||
void createManualMarker("action_detected", `识别到${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, analyzed.confidence);
|
||||
}
|
||||
});
|
||||
actionPoseRef.current = pose;
|
||||
}
|
||||
|
||||
const checkInvalidWindow = () => {
|
||||
const elapsedMs = Date.now() - recordingStartedAtRef.current;
|
||||
if (elapsedMs < INVALID_RECORDING_WINDOW_MS || invalidAutoMarkedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastRecognizedActionAtRef.current < INVALID_RECORDING_WINDOW_MS) {
|
||||
return;
|
||||
}
|
||||
invalidAutoMarkedRef.current = true;
|
||||
setValidityState("invalid", "连续 60 秒未识别到有效动作,已自动标记为无效录制", validityOverrideRef.current);
|
||||
void createManualMarker("invalid_auto", "连续60秒未识别到有效动作,自动标记为无效录制");
|
||||
};
|
||||
|
||||
actionTickerRef.current = setInterval(() => {
|
||||
const video = liveVideoRef.current;
|
||||
if (!video || video.readyState < 2 || !actionPoseRef.current || actionFrameInFlightRef.current) {
|
||||
checkInvalidWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
actionFrameInFlightRef.current = true;
|
||||
actionPoseRef.current.send({ image: video })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
actionFrameInFlightRef.current = false;
|
||||
checkInvalidWindow();
|
||||
});
|
||||
}, ACTION_SAMPLE_MS);
|
||||
}, [createManualMarker, setValidityState]);
|
||||
|
||||
const stopActionSampling = useCallback(async () => {
|
||||
if (actionTickerRef.current) {
|
||||
clearInterval(actionTickerRef.current);
|
||||
actionTickerRef.current = null;
|
||||
}
|
||||
if (actionPoseRef.current?.close) {
|
||||
try {
|
||||
await actionPoseRef.current.close();
|
||||
} catch {
|
||||
// ignore pose teardown failures during recorder stop/reset
|
||||
}
|
||||
}
|
||||
actionPoseRef.current = null;
|
||||
actionFrameInFlightRef.current = false;
|
||||
}, []);
|
||||
|
||||
const updateRecordingValidity = useCallback(async (next: "valid" | "invalid") => {
|
||||
validityOverrideRef.current = next;
|
||||
if (next === "valid") {
|
||||
setValidityState("valid", "已手工恢复为有效录制", "valid");
|
||||
invalidAutoMarkedRef.current = false;
|
||||
await createManualMarker("valid_manual", "手工恢复为有效录制");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidityState("invalid", "已手工标记为无效录制", "invalid");
|
||||
await createManualMarker("invalid_manual", "手工标记为无效录制");
|
||||
}, [createManualMarker, setValidityState]);
|
||||
|
||||
const sampleMotion = useCallback(() => {
|
||||
const video = liveVideoRef.current;
|
||||
const canvas = motionCanvasRef.current;
|
||||
@@ -680,11 +863,22 @@ export default function Recorder() {
|
||||
segmentSequenceRef.current = 0;
|
||||
motionFrameRef.current = null;
|
||||
pendingUploadsRef.current = [];
|
||||
actionTrackingRef.current = {};
|
||||
actionHistoryRef.current = [];
|
||||
actionSummaryRef.current = createActionSummary();
|
||||
setActionSummary(createActionSummary());
|
||||
setCurrentDetectedAction("unknown");
|
||||
setRecordingValidity("pending");
|
||||
setRecordingValidityReason("正在抽样动作帧,持续 60 秒未识别到有效动作将自动标记无效。");
|
||||
validityOverrideRef.current = null;
|
||||
invalidAutoMarkedRef.current = false;
|
||||
latestRecognizedActionRef.current = "unknown";
|
||||
lastActionMarkerAtRef.current = 0;
|
||||
|
||||
const stream = await ensurePreviewStream();
|
||||
const sessionResponse = await createMediaSession({
|
||||
userId: String(user.id),
|
||||
title: title.trim() || `训练录制 ${new Date().toLocaleString("zh-CN")}`,
|
||||
title: title.trim() || `训练录制 ${formatDateTimeShanghai(new Date())}`,
|
||||
format: "webm",
|
||||
mimeType,
|
||||
qualityPreset,
|
||||
@@ -695,14 +889,16 @@ export default function Recorder() {
|
||||
await startRealtimePush(stream, sessionResponse.session.id);
|
||||
|
||||
recordingStartedAtRef.current = Date.now();
|
||||
lastRecognizedActionAtRef.current = recordingStartedAtRef.current;
|
||||
startRecorderLoop(stream);
|
||||
await startActionSampling();
|
||||
setMode("recording");
|
||||
toast.success("录制已开始,已同步启动实时推流");
|
||||
} catch (error: any) {
|
||||
setMode("idle");
|
||||
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
|
||||
}
|
||||
}, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
|
||||
}, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startActionSampling, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
|
||||
|
||||
const finishRecording = useCallback(async () => {
|
||||
const session = currentSessionRef.current;
|
||||
@@ -712,6 +908,7 @@ export default function Recorder() {
|
||||
|
||||
try {
|
||||
setMode("finalizing");
|
||||
await stopActionSampling();
|
||||
await stopRecorder();
|
||||
await flushPendingSegments();
|
||||
closePeer();
|
||||
@@ -728,13 +925,25 @@ export default function Recorder() {
|
||||
exerciseType: "recording",
|
||||
sessionMode,
|
||||
durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)),
|
||||
actionCount: Object.entries(actionSummaryRef.current)
|
||||
.filter(([actionType]) => actionType !== "unknown")
|
||||
.reduce((sum, [, count]) => sum + count, 0),
|
||||
actionSummary: actionSummaryRef.current,
|
||||
dominantAction: latestRecognizedActionRef.current !== "unknown" ? latestRecognizedActionRef.current : undefined,
|
||||
validityStatus:
|
||||
recordingValidity === "invalid"
|
||||
? validityOverrideRef.current === "invalid" ? "invalid_manual" : "invalid_auto"
|
||||
: recordingValidity === "valid"
|
||||
? validityOverrideRef.current === "valid" ? "valid_manual" : "valid"
|
||||
: "pending",
|
||||
invalidReason: recordingValidity === "invalid" ? recordingValidityReason : undefined,
|
||||
});
|
||||
toast.success("录制已提交,后台正在整理回放文件");
|
||||
} catch (error: any) {
|
||||
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
|
||||
setMode("recording");
|
||||
}
|
||||
}, [closePeer, finalizeTaskMutation, flushPendingSegments, sessionMode, stopCamera, stopRecorder, syncSessionState, title]);
|
||||
}, [closePeer, finalizeTaskMutation, flushPendingSegments, recordingValidity, recordingValidityReason, sessionMode, stopActionSampling, stopCamera, stopRecorder, syncSessionState, title]);
|
||||
|
||||
const resetRecorder = useCallback(async () => {
|
||||
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
||||
@@ -745,10 +954,18 @@ export default function Recorder() {
|
||||
pendingUploadsRef.current = [];
|
||||
uploadInFlightRef.current = false;
|
||||
motionFrameRef.current = null;
|
||||
await stopActionSampling().catch(() => {});
|
||||
actionTrackingRef.current = {};
|
||||
actionHistoryRef.current = [];
|
||||
actionSummaryRef.current = createActionSummary();
|
||||
currentSessionRef.current = null;
|
||||
setArchiveTaskId(null);
|
||||
setMediaSession(null);
|
||||
setMarkers([]);
|
||||
setActionSummary(createActionSummary());
|
||||
setCurrentDetectedAction("unknown");
|
||||
setRecordingValidity("pending");
|
||||
setRecordingValidityReason("");
|
||||
setDurationMs(0);
|
||||
setQueuedSegments(0);
|
||||
setQueuedBytes(0);
|
||||
@@ -758,7 +975,7 @@ export default function Recorder() {
|
||||
setConnectionState("new");
|
||||
setCameraError("");
|
||||
setMode("idle");
|
||||
}, [closePeer, stopCamera, stopRecorder, stopTickers]);
|
||||
}, [closePeer, stopActionSampling, stopCamera, stopRecorder, stopTickers]);
|
||||
|
||||
const flipCamera = useCallback(async () => {
|
||||
const nextFacingMode = facingMode === "user" ? "environment" : "user";
|
||||
@@ -844,6 +1061,7 @@ export default function Recorder() {
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
||||
stopTickers();
|
||||
void stopActionSampling();
|
||||
if (recorderRef.current && recorderRef.current.state !== "inactive") {
|
||||
try {
|
||||
recorderRef.current.stop();
|
||||
@@ -856,7 +1074,7 @@ export default function Recorder() {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
}, [closePeer, stopTickers]);
|
||||
}, [closePeer, stopActionSampling, stopTickers]);
|
||||
|
||||
const statusBadge = useMemo(() => {
|
||||
if (mode === "finalizing") {
|
||||
@@ -1333,6 +1551,53 @@ export default function Recorder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl bg-muted/35 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">动作有效性</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
{recordingValidity === "valid" ? "有效录制" : recordingValidity === "invalid" ? "无效录制" : "待判定"}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{recordingValidityReason || "录制中会自动抽样动作帧并进行判定。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/35 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前识别动作</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
{RECOGNIZED_ACTION_LABELS[currentDetectedAction] || "未知动作"}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
每 {Math.round(ACTION_SAMPLE_MS / 1000)} 秒抽样动作帧;连续 {Math.round(INVALID_RECORDING_WINDOW_MS / 1000)} 秒无有效动作会自动标记无效。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void updateRecordingValidity("valid")}
|
||||
disabled={!currentSessionRef.current}
|
||||
>
|
||||
手工恢复有效
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void updateRecordingValidity("invalid")}
|
||||
disabled={!currentSessionRef.current}
|
||||
>
|
||||
手工标记无效
|
||||
</Button>
|
||||
{mediaSession?.playback.previewUrl ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={mediaSession.playback.previewUrl} target="_blank" rel="noreferrer">
|
||||
查看滚动预归档
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>已上传文件</span>
|
||||
@@ -1354,6 +1619,13 @@ export default function Recorder() {
|
||||
<span>服务端状态</span>
|
||||
<span className="font-medium">{mediaSession?.status || "idle"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>滚动预归档</span>
|
||||
<span className="font-medium">
|
||||
{mediaSession?.previewStatus || "idle"}
|
||||
{typeof mediaSession?.previewSegments === "number" ? ` · ${mediaSession.previewSegments} 段` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(mode === "finalizing" || mode === "archived" || mediaSession?.archiveStatus === "failed") && (
|
||||
@@ -1404,6 +1676,19 @@ export default function Recorder() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recognizedActionItems.length > 0 ? (
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
|
||||
<div className="text-sm font-medium">识别到的动作数据</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{recognizedActionItems.map((item) => (
|
||||
<Badge key={item.actionType} variant="secondary">
|
||||
{item.label} {item.count} 次
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{cameraError && (
|
||||
<div className="rounded-2xl border border-destructive/20 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{cameraError}
|
||||
|
||||
在新工单中引用
屏蔽一个用户