添加家人绑定

This commit is contained in:
2026-04-17 11:32:17 +08:00
parent 399bdf079b
commit 8c9d86e858
25 changed files with 2631 additions and 87 deletions
+211
View File
@@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { startTransition, useState } from "react";
export function BindDeviceForm() {
const router = useRouter();
const [rawCode, setRawCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!rawCode.trim()) {
setNotice("先把老人二维码里的设备码贴进来。\n");
return;
}
setSubmitting(true);
setNotice("正在建立连接,请稍候……");
try {
const response = await fetch("/api/family/bind", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceUuid: rawCode }),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "连接失败,请稍后再试。");
}
setNotice("已经连上了,这位老人的留言和陪伴记录会出现在下方。\n");
setRawCode("");
startTransition(() => {
router.refresh();
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "连接失败,请稍后再试。",
);
} finally {
setSubmitting(false);
}
}
return (
<div className="rounded-[28px] border border-white/70 bg-white/80 p-5 shadow-[0_24px_60px_rgba(125,80,46,0.12)] backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-xl">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
家人连接
</p>
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
扫二维码,或者直接贴入设备码
</h3>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
如果老人已经把二维码发给您,直接扫码最快;如果您拿到的是一段链接或设备码,也可以贴到下面。
</p>
</div>
<Link
href="/scan"
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
>
打开扫码页
</Link>
</div>
<form className="mt-5 flex flex-col gap-3 lg:flex-row" onSubmit={handleSubmit}>
<input
value={rawCode}
onChange={(event) => setRawCode(event.target.value)}
placeholder="粘贴二维码链接,或者直接贴设备 UUID"
className="h-13 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
/>
<button
type="submit"
disabled={submitting}
className="inline-flex h-13 min-w-36 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? "正在连接" : "立即绑定"}
</button>
</form>
{notice ? (
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
</div>
);
}
type SendMessageFormProps = {
deviceUuid: string;
};
export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
const router = useRouter();
const [content, setContent] = useState("");
const [importance, setImportance] = useState("NORMAL");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!content.trim()) {
setNotice("先写下想对老人说的话。\n");
return;
}
setSubmitting(true);
setNotice("正在送达这条留言……");
try {
const response = await fetch("/api/family/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
deviceUuid,
content,
importance,
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "留言发送失败,请稍后再试。");
}
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
setContent("");
setImportance("NORMAL");
startTransition(() => {
router.refresh();
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "留言发送失败,请稍后再试。",
);
} finally {
setSubmitting(false);
}
}
return (
<form className="rounded-[28px] border border-white/70 bg-white/88 p-5" onSubmit={handleSubmit}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
给老人留句话
</p>
<h4 className="mt-2 font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
写一句今天就能被看到的问候
</h4>
</div>
<select
value={importance}
onChange={(event) => setImportance(event.target.value)}
className="h-11 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none"
>
<option value="LOW">温和提醒</option>
<option value="NORMAL">日常问候</option>
<option value="HIGH">希望尽快看到</option>
<option value="URGENT">需要尽快联系</option>
</select>
</div>
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={4}
placeholder="例如:妈,晚上天气凉,记得添件衣服。晚饭后我给您打电话。"
className="mt-4 w-full rounded-[24px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-4 text-sm leading-7 text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
/>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm leading-7 text-[var(--muted)]">
这条话会进入老人的留言列表,也会被 function calling 工具读取到。
</p>
<button
type="submit"
disabled={submitting}
className="inline-flex h-12 min-w-32 items-center justify-center rounded-full bg-[var(--ink)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? "正在送达" : "写好并送出"}
</button>
</div>
{notice ? (
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
</form>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import QrScanner from "qr-scanner";
import { useRouter } from "next/navigation";
import {
startTransition,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
function extractDeviceUuid(rawValue: string) {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return null;
}
try {
const parsedUrl = new URL(trimmedValue);
return (
parsedUrl.searchParams.get("deviceUuid") ||
parsedUrl.searchParams.get("device") ||
null
);
} catch {
return trimmedValue;
}
}
export function ScanClient() {
const router = useRouter();
const videoRef = useRef<HTMLVideoElement | null>(null);
const scannerRef = useRef<QrScanner | null>(null);
const [statusText, setStatusText] = useState("正在准备镜头……");
const [errorText, setErrorText] = useState<string | null>(null);
const handleDecode = useEffectEvent(async (result: { data: string }) => {
const scannedValue = extractDeviceUuid(result.data);
if (!scannerRef.current || !scannedValue) {
return;
}
scannerRef.current.stop();
setErrorText(null);
setStatusText("识别到了设备码,正在建立连接……");
try {
const response = await fetch("/api/family/bind", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceUuid: scannedValue }),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; deviceUuid?: string }
| null;
const deviceUuid = payload?.deviceUuid;
if (!response.ok || !deviceUuid) {
throw new Error(payload?.error || "绑定失败,请重新扫描一次。");
}
startTransition(() => {
router.replace(
`/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`,
);
});
} catch (error) {
setErrorText(
error instanceof Error ? error.message : "绑定失败,请重新扫描一次。",
);
setStatusText("请再把二维码对准镜头中央。");
await scannerRef.current.start().catch(() => null);
}
});
useEffect(() => {
let cancelled = false;
async function startScanner() {
if (!videoRef.current) {
return;
}
try {
const hasCamera = await QrScanner.hasCamera();
if (!hasCamera) {
setErrorText("当前设备没有可用摄像头,请改用系统扫码或手动粘贴设备码。");
setStatusText("无法打开镜头");
return;
}
const scanner = new QrScanner(
videoRef.current,
(result) => {
void handleDecode(result as { data: string });
},
{
preferredCamera: "environment",
highlightCodeOutline: true,
highlightScanRegion: true,
returnDetailedScanResult: true,
onDecodeError: () => {},
},
);
scannerRef.current = scanner;
await scanner.start();
if (!cancelled) {
setStatusText("请把老人的二维码放到取景框中央。");
}
} catch (error) {
setErrorText(
error instanceof Error
? error.message
: "镜头启动失败,请检查权限后重试。",
);
setStatusText("镜头启动失败");
}
}
void startScanner();
return () => {
cancelled = true;
scannerRef.current?.destroy();
scannerRef.current = null;
};
}, []);
return (
<div className="rounded-[32px] border border-white/70 bg-white/82 p-5 shadow-[0_30px_70px_rgba(115,72,44,0.14)] backdrop-blur">
<div className="relative overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
<video
ref={videoRef}
muted
playsInline
className="aspect-[3/4] w-full object-cover"
/>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0_28%,rgba(10,7,5,0.48)_29%_100%)]" />
<div className="pointer-events-none absolute inset-x-[16%] top-[20%] aspect-square rounded-[28px] border-2 border-white/85 shadow-[0_0_0_999px_rgba(0,0,0,0.18)]" />
</div>
<div className="mt-4 space-y-2">
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
<p className="text-sm leading-7 text-[var(--muted)]">
扫到后会直接把这位老人绑定到当前家属账号,不需要额外输密码。
</p>
{errorText ? (
<p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]">
{errorText}
</p>
) : null}
</div>
</div>
);
}