优化ui
This commit is contained in:
@@ -1,73 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
async function api(path: string, method: string, body: Record<string, unknown>) {
|
||||
const response = await fetch(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || "操作失败,请稍后再试。");
|
||||
return payload;
|
||||
}
|
||||
|
||||
function Notice({ text }: { text: string }) { return text ? <p style={{ margin: "8px 0 0", color: "var(--success)", fontSize: 12 }}>{text}</p> : null; }
|
||||
function Notice({ text }: { text: string }) {
|
||||
return text ? <p className="form-notice">{text}</p> : null;
|
||||
}
|
||||
|
||||
export function FamilyPostForm({ deviceUuid }: { deviceUuid: string }) {
|
||||
const router = useRouter(); const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); setBusy(true); const form = new FormData(event.currentTarget);
|
||||
try { await api("/api/family-feed", "POST", { deviceUuid, content: form.get("content"), visibility: form.get("visibility") }); event.currentTarget.reset(); setNotice("已发布到家庭动态。"); router.refresh(); }
|
||||
catch (e) { setNotice(e instanceof Error ? e.message : "发布失败。"); } finally { setBusy(false); }
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
await api("/api/family-feed", "POST", {
|
||||
deviceUuid,
|
||||
content: form.get("content"),
|
||||
visibility: form.get("visibility"),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
setNotice("已发布到家庭动态。");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "发布失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return <form className="card card-pad" onSubmit={submit}><textarea className="field" name="content" required maxLength={1000} placeholder="分享一句问候,或和家人同步今天的情况……" /><div className="button-row" style={{ marginTop: 8 }}><select className="field" name="visibility" defaultValue="FAMILY_AND_ELDER"><option value="FAMILY_AND_ELDER">老人和全家可见</option><option value="ELDER_ONLY">只发给老人</option><option value="FAMILY_ONLY">仅家庭内部</option></select><button className="btn primary" disabled={busy}>{busy ? "发布中" : "发布"}</button></div><Notice text={notice} /></form>;
|
||||
|
||||
return (
|
||||
<form className="card card-pad" onSubmit={submit}>
|
||||
<label className="field-label" htmlFor="family-post-content">发一条家庭动态</label>
|
||||
<textarea id="family-post-content" className="field" name="content" required maxLength={1000} placeholder="分享一句问候,或和家人同步今天的情况……" style={{ minHeight: 88 }} />
|
||||
<div className="form-grid publish" style={{ marginTop: 10 }}>
|
||||
<select className="field" name="visibility" defaultValue="FAMILY_AND_ELDER" aria-label="动态可见范围">
|
||||
<option value="FAMILY_AND_ELDER">老人和全家可见</option>
|
||||
<option value="ELDER_ONLY">只发给老人</option>
|
||||
<option value="FAMILY_ONLY">仅家庭内部</option>
|
||||
</select>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "发布中" : "发布"}</button>
|
||||
</div>
|
||||
<Notice text={notice} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoForm({ deviceUuid }: { deviceUuid: string }) {
|
||||
const router = useRouter(); const [notice, setNotice] = useState("");
|
||||
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { await api("/api/memos", "POST", { deviceUuid, title: form.get("title"), content: form.get("content"), priority: form.get("priority"), visibility: form.get("visibility") }); event.currentTarget.reset(); setNotice("家庭备忘已保存。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "保存失败。"); } }
|
||||
return <form className="card card-pad" onSubmit={submit}><input className="field" name="title" required maxLength={80} placeholder="备忘标题,如:医保卡位置" /><textarea className="field" style={{ marginTop: 8 }} name="content" required maxLength={1000} placeholder="写下家人需要共同记住的信息" /><div className="button-row" style={{ marginTop: 8 }}><select className="field" name="priority"><option value="NORMAL">普通</option><option value="IMPORTANT">重要</option><option value="PINNED">长期置顶</option></select><select className="field" name="visibility"><option value="FAMILY_AND_ELDER">可告诉老人</option><option value="FAMILY_ONLY">仅家人可见</option></select><button className="btn primary">保存</button></div><Notice text={notice} /></form>;
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
await api("/api/memos", "POST", {
|
||||
deviceUuid,
|
||||
title: form.get("title"),
|
||||
content: form.get("content"),
|
||||
priority: form.get("priority"),
|
||||
visibility: form.get("visibility"),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
setNotice("家庭备忘已保存。");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "保存失败。");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card card-pad" onSubmit={submit}>
|
||||
<label className="field-label" htmlFor="memo-title">新建家庭备忘</label>
|
||||
<div className="form-stack">
|
||||
<input id="memo-title" className="field" name="title" required maxLength={80} placeholder="备忘标题,如:医保卡位置" />
|
||||
<textarea className="field" name="content" required maxLength={1000} placeholder="写下家人需要共同记住的信息" />
|
||||
<div className="form-grid memo-actions">
|
||||
<select className="field" name="priority" aria-label="备忘优先级">
|
||||
<option value="NORMAL">普通</option>
|
||||
<option value="IMPORTANT">重要</option>
|
||||
<option value="PINNED">长期置顶</option>
|
||||
</select>
|
||||
<select className="field" name="visibility" aria-label="备忘可见范围">
|
||||
<option value="FAMILY_AND_ELDER">可告诉老人</option>
|
||||
<option value="FAMILY_ONLY">仅家人可见</option>
|
||||
</select>
|
||||
<button className="btn primary">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<Notice text={notice} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskForm({ deviceUuid }: { deviceUuid: string }) {
|
||||
const router = useRouter(); const [notice, setNotice] = useState("");
|
||||
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { await api("/api/care-tasks", "POST", { deviceUuid, title: form.get("title"), description: form.get("description"), dueAt: form.get("dueAt") }); event.currentTarget.reset(); setNotice("照护任务已创建。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "创建失败。"); } }
|
||||
return <form className="card card-pad" onSubmit={submit}><input className="field" name="title" required maxLength={100} placeholder="任务,如:陪爷爷周三复诊" /><input className="field" style={{ marginTop: 8 }} name="description" maxLength={500} placeholder="补充说明(可选)" /><div className="button-row" style={{ marginTop: 8 }}><input className="field" name="dueAt" type="datetime-local" /><button className="btn primary">创建任务</button></div><Notice text={notice} /></form>;
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
await api("/api/care-tasks", "POST", {
|
||||
deviceUuid,
|
||||
title: form.get("title"),
|
||||
description: form.get("description"),
|
||||
dueAt: form.get("dueAt"),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
setNotice("照护任务已创建,等待家人认领。");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "创建失败。");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card card-pad" onSubmit={submit}>
|
||||
<label className="field-label" htmlFor="task-title">新建照护任务</label>
|
||||
<div className="form-stack">
|
||||
<input id="task-title" className="field" name="title" required maxLength={100} placeholder="任务,如:陪爷爷周三复诊" />
|
||||
<input className="field" name="description" maxLength={500} placeholder="补充说明(可选)" />
|
||||
<div className="form-grid action">
|
||||
<input className="field" name="dueAt" type="datetime-local" aria-label="任务期限" />
|
||||
<button className="btn primary">创建任务</button>
|
||||
</div>
|
||||
</div>
|
||||
<Notice text={notice} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskActions({ taskId, status }: { taskId: string; status: string }) {
|
||||
const router = useRouter(); const [busy, setBusy] = useState(false);
|
||||
async function act(action: string) { setBusy(true); try { let note: string | undefined; if (action === "complete") note = window.prompt("补充完成说明(可留空)") || undefined; await api("/api/care-tasks", "PATCH", { taskId, action, note }); router.refresh(); } finally { setBusy(false); } }
|
||||
if (status === "COMPLETED") return <span className="tag">已完成</span>;
|
||||
return <div className="button-row"><button className="btn small" disabled={busy} onClick={() => void act("claim")}>{status === "CLAIMED" ? "接手人" : "我来处理"}</button><button className="btn primary small" disabled={busy} onClick={() => void act("complete")}>完成</button></div>;
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function act(action: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
let note: string | undefined;
|
||||
if (action === "complete") note = window.prompt("补充完成说明(可留空)") || undefined;
|
||||
await api("/api/care-tasks", "PATCH", { taskId, action, note });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "COMPLETED") return <span className="tag success" style={{ marginTop: 9 }}>已完成</span>;
|
||||
|
||||
return (
|
||||
<div className="button-row">
|
||||
<button className="btn small" disabled={busy} onClick={() => void act("claim")}>{status === "CLAIMED" ? "接手人" : "我来处理"}</button>
|
||||
<button className="btn primary small" disabled={busy} onClick={() => void act("complete")}>完成</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MedicationForm({ deviceUuid }: { deviceUuid: string }) {
|
||||
const router = useRouter(); const [notice, setNotice] = useState("");
|
||||
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { await api("/api/medications", "POST", { deviceUuid, name: form.get("name"), dosage: form.get("dosage"), times: form.get("times"), instructions: form.get("instructions"), source: form.get("source"), stockCount: Number(form.get("stockCount") || 0) || undefined }); event.currentTarget.reset(); setNotice("未来 7 天的用药提醒已建立。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "创建失败。"); } }
|
||||
return <form className="card card-pad" onSubmit={submit}><div className="button-row"><input className="field" name="name" required placeholder="药品名称" /><input className="field" name="dosage" required placeholder="单次用量,如:1片" /></div><div className="button-row" style={{ marginTop: 8 }}><input className="field" name="times" required placeholder="时间,如:08:00,20:00" /><select className="field" name="source"><option value="PRESCRIPTION">医生处方</option><option value="PACKAGE_LABEL">药盒标签</option><option value="FAMILY_ENTRY">家属录入</option></select></div><input className="field" style={{ marginTop: 8 }} name="instructions" placeholder="饭前/饭后及其他注意事项" /><input className="field" style={{ marginTop: 8 }} name="stockCount" type="number" min="0" placeholder="当前库存(可选)" /><button className="btn primary block" style={{ marginTop: 8 }}>建立提醒</button><Notice text={notice} /></form>;
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
await api("/api/medications", "POST", {
|
||||
deviceUuid,
|
||||
name: form.get("name"),
|
||||
dosage: form.get("dosage"),
|
||||
times: form.get("times"),
|
||||
instructions: form.get("instructions"),
|
||||
source: form.get("source"),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
setNotice("未来 7 天的用药提醒已建立。");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "创建失败。");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card card-pad" onSubmit={submit}>
|
||||
<label className="field-label" htmlFor="medication-name">新建用药提醒</label>
|
||||
<div className="form-stack">
|
||||
<div className="form-grid two">
|
||||
<input id="medication-name" className="field" name="name" required placeholder="药品名称" />
|
||||
<input className="field" name="dosage" required placeholder="单次用量,如 1 片" />
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
<input className="field" name="times" required placeholder="时间,如 08:00, 20:00" />
|
||||
<select className="field" name="source" aria-label="用药信息来源">
|
||||
<option value="PRESCRIPTION">医生处方</option>
|
||||
<option value="PACKAGE_LABEL">药盒标签</option>
|
||||
<option value="FAMILY_ENTRY">家属录入</option>
|
||||
</select>
|
||||
</div>
|
||||
<input className="field" name="instructions" placeholder="饭前 / 饭后及其他注意事项(可选)" />
|
||||
<button className="btn primary block">建立未来 7 天提醒</button>
|
||||
</div>
|
||||
<Notice text={notice} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckInButton({ deviceUuid }: { deviceUuid: string }) {
|
||||
const router = useRouter(); const [notice, setNotice] = useState("");
|
||||
async function create() { try { await api("/api/check-ins", "POST", { deviceUuid, prompt: "家里人想知道您今天是否安好。" }); setNotice("报平安请求已送到长辈终端。"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "发送失败。"); } }
|
||||
return <div><button className="btn primary block" onClick={() => void create()}>请长辈报个平安</button><Notice text={notice} /></div>;
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function create() {
|
||||
try {
|
||||
await api("/api/check-ins", "POST", { deviceUuid, prompt: "家里人想知道您今天是否安好。" });
|
||||
setNotice("报平安请求已送到长辈终端,回应后会通知全家。");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "发送失败。");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="checkin-action">
|
||||
<button className="btn primary block" onClick={() => void create()}><ShieldCheck size={16} strokeWidth={2} />请长辈报个平安</button>
|
||||
<Notice text={notice} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertActions({ alertId, status }: { alertId: string; status: string }) {
|
||||
const router = useRouter(); const [busy, setBusy] = useState(false);
|
||||
async function act(action: string) { setBusy(true); try { let note; if (action !== "claim") { note = window.prompt(action === "resolve" ? "请填写处理结果" : "请填写忽略原因") || ""; if (!note && action === "resolve") return; } await api("/api/alerts", "PATCH", { alertId, action, note }); router.refresh(); } finally { setBusy(false); } }
|
||||
if (status === "RESOLVED" || status === "DISMISSED") return <span className="tag">已处理</span>;
|
||||
return <div className="button-row"><button className="btn small" disabled={busy} onClick={() => void act("claim")}>我来处理</button><button className="btn primary small" disabled={busy} onClick={() => void act("resolve")}>填写结果</button><button className="btn small" disabled={busy} onClick={() => void act("dismiss")}>误报</button></div>;
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function act(action: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
let note;
|
||||
if (action !== "claim") {
|
||||
note = window.prompt(action === "resolve" ? "请填写处理结果" : "请填写忽略原因") || "";
|
||||
if (!note && action === "resolve") return;
|
||||
}
|
||||
await api("/api/alerts", "PATCH", { alertId, action, note });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "RESOLVED" || status === "DISMISSED") return <span className="tag success" style={{ marginTop: 9 }}>已处理</span>;
|
||||
|
||||
return (
|
||||
<div className="button-row">
|
||||
<button className="btn small" disabled={busy} onClick={() => void act("claim")}>我来处理</button>
|
||||
<button className="btn primary small" disabled={busy} onClick={() => void act("resolve")}>填写结果</button>
|
||||
<button className="btn small muted" disabled={busy} onClick={() => void act("dismiss")}>误报</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteMemberForm({ deviceUuid }: { deviceUuid: string }) {
|
||||
const [url, setUrl] = useState(""); const [notice, setNotice] = useState("");
|
||||
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = new FormData(event.currentTarget); try { const result = await api("/api/family-circle/invite", "POST", { deviceUuid, relationLabel: form.get("relationLabel"), role: form.get("role") }); setUrl(result.inviteUrl); setNotice("邀请链接 24 小时内有效。"); } catch (e) { setNotice(e instanceof Error ? e.message : "邀请失败。"); } }
|
||||
return <form className="card card-pad" onSubmit={submit}><div className="button-row"><input className="field" name="relationLabel" placeholder="关系,如:儿子" /><select className="field" name="role"><option value="CAREGIVER">照护成员</option><option value="CARING">关怀成员</option></select><button className="btn primary">生成邀请</button></div>{url ? <div className="button-row" style={{ marginTop: 8 }}><input className="field" readOnly value={url} /><button type="button" className="btn" onClick={() => void navigator.clipboard.writeText(url)}>复制</button></div> : null}<Notice text={notice} /></form>;
|
||||
const [url, setUrl] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const result = await api("/api/family-circle/invite", "POST", {
|
||||
deviceUuid,
|
||||
relationLabel: form.get("relationLabel"),
|
||||
role: form.get("role"),
|
||||
});
|
||||
setUrl(result.inviteUrl);
|
||||
setCopied(false);
|
||||
setNotice("邀请链接 24 小时内有效,发给家人即可加入。");
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "邀请失败。");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card card-pad" onSubmit={submit}>
|
||||
<div className="form-grid two">
|
||||
<span>
|
||||
<label className="field-label" htmlFor="invite-relation">与长辈的关系</label>
|
||||
<input id="invite-relation" className="field" name="relationLabel" placeholder="如:儿子" />
|
||||
</span>
|
||||
<span>
|
||||
<label className="field-label" htmlFor="invite-role">成员角色</label>
|
||||
<select id="invite-role" className="field" name="role">
|
||||
<option value="CAREGIVER">照护成员</option>
|
||||
<option value="CARING">关怀成员</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn dark block" style={{ marginTop: 10 }}>生成邀请链接</button>
|
||||
{url ? (
|
||||
<div className="form-grid" style={{ gridTemplateColumns: "minmax(0, 1fr) 68px", marginTop: 10 }}>
|
||||
<input className="field" readOnly value={url} style={{ height: 40, background: "var(--surface-soft)", color: "var(--muted)", fontSize: 12 }} />
|
||||
<button type="button" className="btn" style={{ minHeight: 40, padding: 0 }} onClick={() => void copyInvite()}>{copied ? "已复制" : "复制"}</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Notice text={notice} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function JoinFamilyButton({ token }: { token: string }) {
|
||||
const router = useRouter(); const [notice, setNotice] = useState("");
|
||||
async function join() { try { await api("/api/family-circle/join", "POST", { token }); setNotice("已加入家庭守护圈,正在打开家庭页……"); router.push("/family"); router.refresh(); } catch (e) { setNotice(e instanceof Error ? e.message : "加入失败。"); } }
|
||||
return <div><button className="btn primary block" onClick={() => void join()}>接受邀请并加入</button><Notice text={notice} /></div>;
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function join() {
|
||||
try {
|
||||
await api("/api/family-circle/join", "POST", { token });
|
||||
setNotice("已加入家庭守护圈,正在打开家庭页……");
|
||||
router.push("/family");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "加入失败。");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button className="btn primary block" onClick={() => void join()}>接受邀请并加入</button>
|
||||
<Notice text={notice} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import Link from "next/link";
|
||||
import { ChevronLeft, HeartHandshake, House, ShieldCheck, Stethoscope, UserRound } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { getCaregiverUnreadIncomingCount } from "@/lib/caregiver-panel";
|
||||
|
||||
const navigationItems = [
|
||||
{ href: "/guard", label: "守护", icon: ShieldCheck },
|
||||
{ href: "/family", label: "家庭", icon: House },
|
||||
@@ -19,6 +21,7 @@ type PanelShellProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
caregiverLabel: string;
|
||||
caregiverId?: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
backHref?: string;
|
||||
@@ -26,18 +29,22 @@ type PanelShellProps = {
|
||||
unreadCount?: number;
|
||||
};
|
||||
|
||||
export function PanelShell({
|
||||
export async function PanelShell({
|
||||
currentPath,
|
||||
title,
|
||||
description,
|
||||
caregiverLabel,
|
||||
caregiverId,
|
||||
children,
|
||||
actions,
|
||||
backHref,
|
||||
showTabs = !backHref,
|
||||
unreadCount = 0,
|
||||
unreadCount,
|
||||
}: PanelShellProps) {
|
||||
const initial = caregiverLabel.trim().slice(-1) || "安";
|
||||
const resolvedUnreadCount = unreadCount ?? (showTabs && caregiverId
|
||||
? await getCaregiverUnreadIncomingCount(caregiverId)
|
||||
: 0);
|
||||
|
||||
return (
|
||||
<main className="app-frame">
|
||||
@@ -76,7 +83,7 @@ export function PanelShell({
|
||||
<Link key={item.href} href={item.href} className={`nav-item${active ? " active" : ""}`}>
|
||||
<Icon size={22} strokeWidth={1.8} />
|
||||
<span>{item.label}</span>
|
||||
{item.href === "/family" && unreadCount > 0 ? <span className="nav-badge">{unreadCount}</span> : null}
|
||||
{item.href === "/family" && resolvedUnreadCount > 0 ? <span className="nav-badge">{resolvedUnreadCount}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
+86
-239
@@ -1,43 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, ChevronRight, PenLine, Smartphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const PWA_INSTALL_MARKER_KEY = "dh-pwa-installed";
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{
|
||||
outcome: "accepted" | "dismissed";
|
||||
platform: string;
|
||||
}>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||
};
|
||||
|
||||
type NotificationState =
|
||||
| "checking"
|
||||
| "idle"
|
||||
| "pending"
|
||||
| "enabled"
|
||||
| "blocked"
|
||||
| "unsupported";
|
||||
type NotificationState = "checking" | "idle" | "pending" | "enabled" | "blocked" | "unsupported";
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let index = 0; index < rawData.length; index += 1) {
|
||||
outputArray[index] = rawData.charCodeAt(index);
|
||||
}
|
||||
|
||||
for (let index = 0; index < rawData.length; index += 1) outputArray[index] = rawData.charCodeAt(index);
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
const navigatorWithStandalone = navigator as Navigator & {
|
||||
standalone?: boolean;
|
||||
};
|
||||
|
||||
const navigatorWithStandalone = navigator as Navigator & { standalone?: boolean };
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
window.matchMedia("(display-mode: fullscreen)").matches ||
|
||||
@@ -63,99 +49,35 @@ function writeInstalledMarker() {
|
||||
}
|
||||
}
|
||||
|
||||
export function PwaControls() {
|
||||
export function PwaControls({ deviceCount }: { deviceCount: number }) {
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
|
||||
const [installPromptEvent, setInstallPromptEvent] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installPromptEvent, setInstallPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [isStandalone, setIsStandalone] = useState(false);
|
||||
const [hasInstalledHint, setHasInstalledHint] = useState(false);
|
||||
const [notificationState, setNotificationState] =
|
||||
useState<NotificationState>("checking");
|
||||
const [notificationState, setNotificationState] = useState<NotificationState>("checking");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const isInstalled = hasInstalledHint || isStandalone;
|
||||
const notificationLabel =
|
||||
notificationState === "enabled"
|
||||
? "消息提醒已开启"
|
||||
: notificationState === "blocked"
|
||||
? "通知权限已被拒绝"
|
||||
: "消息提醒未开启";
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let delayedSyncTimer: number | null = null;
|
||||
|
||||
function syncInstallState() {
|
||||
const standalone = isStandaloneDisplayMode();
|
||||
const installed = standalone || readInstalledMarker();
|
||||
|
||||
if (standalone) {
|
||||
writeInstalledMarker();
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return standalone;
|
||||
}
|
||||
|
||||
setIsStandalone(standalone);
|
||||
setHasInstalledHint(installed);
|
||||
|
||||
return standalone;
|
||||
if (standalone) writeInstalledMarker();
|
||||
if (active) setIsInstalled(standalone || readInstalledMarker());
|
||||
}
|
||||
|
||||
function handleBeforeInstallPrompt(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallPromptEvent(event as BeforeInstallPromptEvent);
|
||||
if (active) setInstallPromptEvent(event as BeforeInstallPromptEvent);
|
||||
}
|
||||
|
||||
function handleAppInstalled() {
|
||||
writeInstalledMarker();
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasInstalledHint(true);
|
||||
if (active) setIsInstalled(true);
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) {
|
||||
syncInstallState();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageShow() {
|
||||
syncInstallState();
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
syncInstallState();
|
||||
}
|
||||
|
||||
async function syncExistingSubscription(subscription: PushSubscription) {
|
||||
await fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
syncInstallState();
|
||||
|
||||
if (
|
||||
!("serviceWorker" in navigator) ||
|
||||
!("PushManager" in window) ||
|
||||
typeof Notification === "undefined"
|
||||
) {
|
||||
async function bootstrapNotifications() {
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window) || typeof Notification === "undefined") {
|
||||
setNotificationState("unsupported");
|
||||
return;
|
||||
}
|
||||
@@ -163,103 +85,74 @@ export function PwaControls() {
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js");
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!active) return;
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingSubscription) {
|
||||
if (subscription) {
|
||||
if (vapidPublicKey) {
|
||||
await syncExistingSubscription(existingSubscription);
|
||||
await fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||
});
|
||||
}
|
||||
|
||||
setNotificationState("enabled");
|
||||
return;
|
||||
} else {
|
||||
setNotificationState(Notification.permission === "denied" ? "blocked" : "idle");
|
||||
}
|
||||
|
||||
setNotificationState(
|
||||
Notification.permission === "denied" ? "blocked" : "idle",
|
||||
);
|
||||
} catch {
|
||||
if (active) {
|
||||
setNotificationState("unsupported");
|
||||
}
|
||||
if (active) setNotificationState("unsupported");
|
||||
}
|
||||
}
|
||||
|
||||
syncInstallState();
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", handleAppInstalled);
|
||||
window.addEventListener("pageshow", handlePageShow);
|
||||
window.addEventListener("focus", handleFocus);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
void bootstrap();
|
||||
delayedSyncTimer = window.setTimeout(() => {
|
||||
syncInstallState();
|
||||
}, 350);
|
||||
window.addEventListener("focus", syncInstallState);
|
||||
void bootstrapNotifications();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", handleAppInstalled);
|
||||
window.removeEventListener("pageshow", handlePageShow);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
if (delayedSyncTimer) {
|
||||
window.clearTimeout(delayedSyncTimer);
|
||||
}
|
||||
window.removeEventListener("focus", syncInstallState);
|
||||
};
|
||||
}, [vapidPublicKey]);
|
||||
|
||||
async function enableNotifications() {
|
||||
if (!vapidPublicKey) {
|
||||
setNotice("当前还没有配置推送服务,先将应用安装到桌面即可。\n");
|
||||
setNotice("当前还没有配置推送服务。");
|
||||
return;
|
||||
}
|
||||
|
||||
setNotificationState("pending");
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
if (permission !== "granted") {
|
||||
setNotificationState(permission === "denied" ? "blocked" : "idle");
|
||||
setNotice(
|
||||
permission === "denied"
|
||||
? "浏览器已经拒绝消息提醒,请在系统设置里重新打开通知权限。\n"
|
||||
: "这次没有打开通知权限,稍后仍可再次开启。\n",
|
||||
);
|
||||
setNotice(permission === "denied" ? "浏览器已拒绝通知权限,请在系统设置中重新开启。" : "这次没有开启通知,稍后仍可再次尝试。");
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
}
|
||||
|
||||
await fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||
});
|
||||
|
||||
setNotificationState("enabled");
|
||||
setNotice("已开启消息提醒,长辈的新留言会直接推送到您的设备。\n");
|
||||
setNotice("已开启紧急提醒推送。");
|
||||
} catch {
|
||||
setNotificationState("idle");
|
||||
setNotice("打开消息提醒失败,请稍后再试。\n");
|
||||
setNotice("打开消息提醒失败,请稍后再试。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,135 +160,89 @@ export function PwaControls() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await fetch("/api/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
setNotificationState("idle");
|
||||
setNotice("这台设备的消息提醒已关闭。\n");
|
||||
setNotice("这台设备的紧急提醒已关闭。");
|
||||
} catch {
|
||||
setNotice("关闭消息提醒失败,请稍后再试。\n");
|
||||
setNotice("关闭消息提醒失败,请稍后再试。");
|
||||
}
|
||||
}
|
||||
|
||||
async function installApp() {
|
||||
if (isInstalled) {
|
||||
setNotice("安智伴已经添加到这台设备的主屏幕。");
|
||||
return;
|
||||
}
|
||||
if (!installPromptEvent) {
|
||||
setNotice("如果浏览器没有弹出安装按钮,可以从菜单里选择“安装应用”或“添加到主屏幕”。\n");
|
||||
setNotice("请从浏览器菜单中选择“安装应用”或“添加到主屏幕”。");
|
||||
return;
|
||||
}
|
||||
|
||||
setInstalling(true);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
await installPromptEvent.prompt();
|
||||
const choice = await installPromptEvent.userChoice;
|
||||
|
||||
if (choice.outcome === "accepted") {
|
||||
writeInstalledMarker();
|
||||
setInstallPromptEvent(null);
|
||||
setHasInstalledHint(true);
|
||||
setNotice("安装提示已经确认,完成后就能像应用一样直接打开。\n");
|
||||
} else {
|
||||
setNotice("这次先关闭了安装提示,之后仍可从浏览器菜单安装。\n");
|
||||
setIsInstalled(true);
|
||||
setNotice("已确认添加到主屏幕。");
|
||||
}
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
const pushOn = notificationState === "enabled";
|
||||
const toggleDisabled = notificationState === "checking" || notificationState === "pending" || notificationState === "unsupported";
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_22px_55px_rgba(53,84,141,0.12)]">
|
||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||
{isInstalled ? "桌面版" : "快捷设置"}
|
||||
</p>
|
||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||
{isInstalled
|
||||
? isStandalone
|
||||
? "已从桌面版打开"
|
||||
: "这台设备已添加到主屏幕"
|
||||
: "安装到桌面,随时收到长辈的消息"}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
||||
{isInstalled
|
||||
? notificationState === "enabled"
|
||||
? "新留言会直接推送到这台设备。"
|
||||
: "如需新留言提醒,可在下方开启通知。"
|
||||
: "安装后打开更快,开启提醒后,长辈每次留言都能立刻收到通知。"}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
||||
{isInstalled ? (
|
||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||
{isStandalone ? "桌面版运行中" : "已添加到主屏幕"}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||
{isStandalone ? "当前正以桌面应用方式运行" : "当前正以浏览器方式运行"}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||
{hasInstalledHint ? "这台设备已有主屏幕安装记录" : "暂未检测到主屏幕安装记录"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||
{notificationLabel}
|
||||
<section className="card">
|
||||
<div className="settings-row">
|
||||
<span className="settings-icon"><Bell size={17} strokeWidth={1.8} /></span>
|
||||
<span className="row-main">
|
||||
<span className="row-title">紧急提醒推送</span>
|
||||
<span className="row-meta">红色风险提醒将第一时间通知本机</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle${pushOn ? " on" : ""}`}
|
||||
disabled={toggleDisabled}
|
||||
onClick={() => void (pushOn ? disableNotifications() : enableNotifications())}
|
||||
aria-label={pushOn ? "关闭紧急提醒推送" : "开启紧急提醒推送"}
|
||||
aria-pressed={pushOn}
|
||||
>
|
||||
<span className="toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{!isInstalled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void installApp();
|
||||
}}
|
||||
disabled={installing || isStandalone}
|
||||
className="inline-flex h-12 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"
|
||||
>
|
||||
{installing ? "正在呼起安装" : "安装到桌面"}
|
||||
</button>
|
||||
) : null}
|
||||
<Link className="settings-row" href="/devices">
|
||||
<span className="settings-icon olive"><Smartphone size={17} strokeWidth={1.8} /></span>
|
||||
<span className="row-main">
|
||||
<span className="row-title">设备管理</span>
|
||||
<span className="row-meta">{deviceCount > 0 ? `已连接 ${deviceCount} 台长辈设备` : "绑定长辈的安智伴设备"}</span>
|
||||
</span>
|
||||
<ChevronRight className="chevron" size={16} strokeWidth={2} />
|
||||
</Link>
|
||||
|
||||
{notificationState === "enabled" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void disableNotifications();
|
||||
}}
|
||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||
>
|
||||
关闭消息提醒
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void enableNotifications();
|
||||
}}
|
||||
disabled={notificationState === "pending" || notificationState === "unsupported"}
|
||||
className="inline-flex h-12 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"
|
||||
>
|
||||
{notificationState === "pending" ? "正在开启提醒" : "打开消息提醒"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="settings-row" onClick={() => void installApp()} disabled={installing}>
|
||||
<span className="settings-icon muted"><PenLine size={17} strokeWidth={1.8} /></span>
|
||||
<span className="row-main">
|
||||
<span className="row-title">添加到主屏幕</span>
|
||||
<span className="row-meta">{isInstalled ? "已添加,可像 App 一样直接打开" : installing ? "正在呼起安装" : "像 App 一样从桌面直接打开"}</span>
|
||||
</span>
|
||||
<ChevronRight className="chevron" size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
{notice ? <p className="form-notice" style={{ margin: 0, padding: "0 16px 13px" }}>{notice}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user