优化路径,添加消息推送
This commit is contained in:
@@ -109,12 +109,14 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
||||
const [importance, setImportance] = useState("NORMAL");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [createdMessagePublicId, setCreatedMessagePublicId] = useState<number | null>(null);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!content.trim()) {
|
||||
setNotice("先写下想对老人说的话。\n");
|
||||
setCreatedMessagePublicId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,7 +137,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| { error?: string; publicId?: number }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -143,12 +145,16 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
||||
}
|
||||
|
||||
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
|
||||
setCreatedMessagePublicId(
|
||||
typeof payload?.publicId === "number" ? payload.publicId : null,
|
||||
);
|
||||
setContent("");
|
||||
setImportance("NORMAL");
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} catch (error) {
|
||||
setCreatedMessagePublicId(null);
|
||||
setNotice(
|
||||
error instanceof Error ? error.message : "留言发送失败,请稍后再试。",
|
||||
);
|
||||
@@ -202,9 +208,17 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
||||
{notice}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2 text-sm leading-7 text-[var(--muted)]">
|
||||
<p className="whitespace-pre-line">{notice}</p>
|
||||
{createdMessagePublicId ? (
|
||||
<Link
|
||||
href={`/messages/${createdMessagePublicId}`}
|
||||
className="inline-flex h-10 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||
>
|
||||
打开留言 #{createdMessagePublicId}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const navigationItems = [
|
||||
{ href: "/", label: "总览" },
|
||||
{ href: "/messages", label: "留言" },
|
||||
{ href: "/devices", label: "设备" },
|
||||
{ href: "/activity", label: "活动" },
|
||||
{ href: "/scan", label: "扫码" },
|
||||
];
|
||||
|
||||
function isActivePath(currentPath: string, href: string) {
|
||||
if (href === "/") {
|
||||
return currentPath === "/";
|
||||
}
|
||||
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
type PanelShellProps = {
|
||||
currentPath: string;
|
||||
title: string;
|
||||
description: string;
|
||||
caregiverToken: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
eyebrow?: string;
|
||||
};
|
||||
|
||||
export function PanelShell({
|
||||
currentPath,
|
||||
title,
|
||||
description,
|
||||
caregiverToken,
|
||||
children,
|
||||
actions,
|
||||
eyebrow = "Digital Human Bridge",
|
||||
}: PanelShellProps) {
|
||||
return (
|
||||
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.8),transparent_70%)]" />
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<header className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-7 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-8">
|
||||
<div className="absolute -right-12 top-[-50px] h-44 w-44 rounded-full bg-[rgba(53,84,141,0.14)] blur-2xl" />
|
||||
<div className="absolute left-10 top-10 h-24 w-24 rounded-full border border-[rgba(118,132,94,0.18)]" />
|
||||
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{navigationItems.map((item) => {
|
||||
const active = isActivePath(currentPath, item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
|
||||
active
|
||||
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
|
||||
: "border border-[var(--line)] bg-white/82 text-[var(--ink)] hover:bg-[var(--paper-soft)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm font-semibold tracking-[0.24em] text-[var(--copper)] uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-4 max-w-3xl font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)] sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)] sm:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
||||
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
||||
当前家属账号:{caregiverToken.slice(0, 8).toUpperCase()}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
||||
不同功能已拆到独立路径,消息支持单条直达
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions ? <div className="w-full max-w-md xl:pt-2">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{
|
||||
outcome: "accepted" | "dismissed";
|
||||
platform: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export function PwaControls() {
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
|
||||
const [installPromptEvent, setInstallPromptEvent] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [isStandalone, setIsStandalone] = useState(false);
|
||||
const [notificationState, setNotificationState] =
|
||||
useState<NotificationState>("checking");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
function handleBeforeInstallPrompt(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallPromptEvent(event as BeforeInstallPromptEvent);
|
||||
}
|
||||
|
||||
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() {
|
||||
const standalone =
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
|
||||
setIsStandalone(standalone);
|
||||
|
||||
if (
|
||||
!("serviceWorker" in navigator) ||
|
||||
!("PushManager" in window) ||
|
||||
typeof Notification === "undefined"
|
||||
) {
|
||||
setNotificationState("unsupported");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js");
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingSubscription) {
|
||||
if (vapidPublicKey) {
|
||||
await syncExistingSubscription(existingSubscription);
|
||||
}
|
||||
|
||||
setNotificationState("enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
setNotificationState(
|
||||
Notification.permission === "denied" ? "blocked" : "idle",
|
||||
);
|
||||
} catch {
|
||||
if (active) {
|
||||
setNotificationState("unsupported");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
void bootstrap();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
};
|
||||
}, [vapidPublicKey]);
|
||||
|
||||
async function enableNotifications() {
|
||||
if (!vapidPublicKey) {
|
||||
setNotice("当前环境还没配置推送密钥,先把面板安装到桌面即可。\n");
|
||||
return;
|
||||
}
|
||||
|
||||
setNotificationState("pending");
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
if (permission !== "granted") {
|
||||
setNotificationState(permission === "denied" ? "blocked" : "idle");
|
||||
setNotice(
|
||||
permission === "denied"
|
||||
? "浏览器已经拒绝消息提醒,请在系统设置里重新打开通知权限。\n"
|
||||
: "这次没有打开通知权限,稍后仍可再次开启。\n",
|
||||
);
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
|
||||
setNotificationState("enabled");
|
||||
setNotice("新留言会直接推到这台设备上,点开即可直达对应留言。\n");
|
||||
} catch {
|
||||
setNotificationState("idle");
|
||||
setNotice("打开消息提醒失败,请稍后再试。\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function disableNotifications() {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
setNotificationState("idle");
|
||||
setNotice("这台设备的消息提醒已关闭。\n");
|
||||
} catch {
|
||||
setNotice("关闭消息提醒失败,请稍后再试。\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function installApp() {
|
||||
if (!installPromptEvent) {
|
||||
setNotice("如果浏览器没有弹出安装按钮,可以从菜单里选择“安装应用”或“添加到主屏幕”。\n");
|
||||
return;
|
||||
}
|
||||
|
||||
setInstalling(true);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
await installPromptEvent.prompt();
|
||||
const choice = await installPromptEvent.userChoice;
|
||||
|
||||
if (choice.outcome === "accepted") {
|
||||
setInstallPromptEvent(null);
|
||||
setNotice("安装提示已经确认,完成后就能像应用一样直接打开。\n");
|
||||
} else {
|
||||
setNotice("这次先关闭了安装提示,之后仍可从浏览器菜单安装。\n");
|
||||
}
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
安装与提醒
|
||||
</p>
|
||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||
把家属面板装到桌面,并接收新留言提醒
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
||||
安装后打开速度会更像原生应用;开启提醒后,老人一旦通过桥接工具留话,点击通知就能直达那条留言。
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
||||
<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">
|
||||
{notificationState === "enabled"
|
||||
? "消息提醒已开启"
|
||||
: notificationState === "blocked"
|
||||
? "通知权限已被拒绝"
|
||||
: "消息提醒未开启"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void installApp();
|
||||
}}
|
||||
disabled={installing}
|
||||
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>
|
||||
|
||||
{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>
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user