添加家人绑定
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user