This commit is contained in:
2026-07-19 19:45:32 +08:00
parent b68bfc6287
commit bbfc055c21
12 changed files with 910 additions and 431 deletions
+311 -34
View File
@@ -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>
);
}
+10 -3
View File
@@ -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
View File
@@ -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>
);
}
}