2026-07-06 06:13:14 +00:00
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
|
|
import { api, ThreadsAccount } from "../api/haixun";
|
|
|
|
|
|
import { navApps, mobileNavKeys } from "../lib/acAssets";
|
|
|
|
|
|
import { persistActiveThreadsAccountId } from "../lib/activeAccount";
|
|
|
|
|
|
import { useAuth } from "../auth/AuthContext";
|
|
|
|
|
|
import { AcIcon } from "./AcIcon";
|
|
|
|
|
|
import { AuthTicketIcon, SceneDecor } from "./AuthDecor";
|
|
|
|
|
|
import { ThemeToggle } from "./ThemeToggle";
|
|
|
|
|
|
import { Button } from "./ui";
|
|
|
|
|
|
import { JobMonitor } from "./JobMonitor";
|
|
|
|
|
|
|
|
|
|
|
|
export function Layout({
|
|
|
|
|
|
active,
|
|
|
|
|
|
onNavigate,
|
|
|
|
|
|
children
|
|
|
|
|
|
}: {
|
|
|
|
|
|
active: string;
|
|
|
|
|
|
onNavigate: (key: string) => void;
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { member, logout } = useAuth();
|
2026-07-07 14:02:41 +00:00
|
|
|
|
const isAdmin = member?.roles?.includes("admin") || false;
|
|
|
|
|
|
const visibleNavApps = navApps.filter((app) => app.key !== "register" || isAdmin);
|
|
|
|
|
|
const activeApp = visibleNavApps.find((app) => app.key === active) || visibleNavApps[0];
|
2026-07-06 06:13:14 +00:00
|
|
|
|
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
|
|
|
|
|
const [activeAccountId, setActiveAccountId] = useState("");
|
|
|
|
|
|
const [switching, setSwitching] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const loadAccounts = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.threadsAccounts();
|
|
|
|
|
|
const nextActiveId = data.active_account_id || data.list?.[0]?.id || "";
|
|
|
|
|
|
setAccounts(data.list || []);
|
|
|
|
|
|
setActiveAccountId(nextActiveId);
|
|
|
|
|
|
persistActiveThreadsAccountId(nextActiveId);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setAccounts([]);
|
|
|
|
|
|
setActiveAccountId("");
|
|
|
|
|
|
persistActiveThreadsAccountId("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let alive = true;
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.threadsAccounts();
|
|
|
|
|
|
if (!alive) return;
|
|
|
|
|
|
const nextActiveId = data.active_account_id || data.list?.[0]?.id || "";
|
|
|
|
|
|
setAccounts(data.list || []);
|
|
|
|
|
|
setActiveAccountId(nextActiveId);
|
|
|
|
|
|
persistActiveThreadsAccountId(nextActiveId);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (alive) {
|
|
|
|
|
|
setAccounts([]);
|
|
|
|
|
|
setActiveAccountId("");
|
|
|
|
|
|
persistActiveThreadsAccountId("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void load();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
alive = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const reload = () => void loadAccounts();
|
|
|
|
|
|
window.addEventListener("haixun:active-account-changed", reload);
|
|
|
|
|
|
return () => window.removeEventListener("haixun:active-account-changed", reload);
|
|
|
|
|
|
}, [loadAccounts]);
|
|
|
|
|
|
|
|
|
|
|
|
async function switchAccount(accountId: string) {
|
|
|
|
|
|
if (!accountId || accountId === activeAccountId) return;
|
|
|
|
|
|
setSwitching(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.activateThreadsAccount(accountId);
|
|
|
|
|
|
setActiveAccountId(accountId);
|
|
|
|
|
|
persistActiveThreadsAccountId(accountId);
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("haixun:active-account-changed", { detail: { accountId } }));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSwitching(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function navigateApp(key: string) {
|
|
|
|
|
|
if (accounts.length === 0 && requiresThreadsAccount(key)) {
|
|
|
|
|
|
onNavigate("accounts");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
onNavigate(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="hx-scene">
|
|
|
|
|
|
<SceneDecor />
|
|
|
|
|
|
<div className="layout-main ac-app-shell-root">
|
|
|
|
|
|
<div className="app-shell">
|
|
|
|
|
|
{/* 目錄區塊:直接貼最左邊、頂天立地,tiles 字體/圖示/排版完全保留 */}
|
|
|
|
|
|
<aside className="ac-pocket-device ac-pocket-full">
|
|
|
|
|
|
<div className="ac-pocket-screen">
|
|
|
|
|
|
<div className="display-en muted ac-sidebar-pad">PATROL PAD</div>
|
|
|
|
|
|
<nav className="ac-pocket-scroll">
|
2026-07-07 14:02:41 +00:00
|
|
|
|
{visibleNavApps.map((app) => (
|
2026-07-06 06:13:14 +00:00
|
|
|
|
(() => {
|
|
|
|
|
|
const locked = accounts.length === 0 && requiresThreadsAccount(app.key);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={app.key}
|
|
|
|
|
|
className={`ac-app-tile ${app.key === active ? "ac-app-tile--active" : ""} ${locked ? "ac-app-tile--locked" : ""}`}
|
|
|
|
|
|
onClick={() => navigateApp(app.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<AcIcon name={app.icon} />
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{app.label}</strong>
|
|
|
|
|
|
<span className="display-en subtle ac-nav-sublabel">{app.sublabel}</span>
|
|
|
|
|
|
{locked ? <span className="subtle ac-nav-hint">先建立帳號</span> : null}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()
|
|
|
|
|
|
))}
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 右側主區域:header 只在右邊,內容盡量寬 */}
|
|
|
|
|
|
<div className="app-main-col">
|
|
|
|
|
|
<header className="ac-app-header">
|
|
|
|
|
|
<div className="button-row">
|
|
|
|
|
|
<AuthTicketIcon />
|
|
|
|
|
|
<div className="ac-header-brand">
|
|
|
|
|
|
<strong>巡樓 Console</strong>
|
|
|
|
|
|
<div className="display-en subtle ac-header-sub">PATROL PAD</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="button-row">
|
|
|
|
|
|
{accounts.length > 0 ? (
|
|
|
|
|
|
<label className="button-row" style={{ gap: "0.35rem" }}>
|
|
|
|
|
|
<span className="display-en subtle ac-header-sub">ACCOUNT</span>
|
|
|
|
|
|
<select className="select" value={activeAccountId} disabled={switching} onChange={(e) => void switchAccount(e.target.value)} style={{ width: "13rem" }}>
|
|
|
|
|
|
{accounts.map((account) => (
|
|
|
|
|
|
<option key={account.id} value={account.id}>
|
|
|
|
|
|
{account.display_name || account.username || account.id}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button className="badge badge-warning" onClick={() => onNavigate("accounts")}>尚未建立帳號</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="badge badge-neutral">{member?.display_name || member?.email || "島民"}</span>
|
|
|
|
|
|
<ThemeToggle />
|
|
|
|
|
|
<Button variant="ghost" onClick={() => void logout()}>登出</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="ac-app-main-inner">
|
|
|
|
|
|
<div className="display-en subtle ac-page-sublabel">{activeApp.sublabel}</div>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<MobileBottomNav active={active} onNavigate={navigateApp} hasAccounts={accounts.length > 0} />
|
|
|
|
|
|
<JobMonitor />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MobileBottomNav({ active, onNavigate, hasAccounts }: { active: string; onNavigate: (key: string) => void; hasAccounts: boolean }) {
|
|
|
|
|
|
const apps = navApps.filter((app) => mobileNavKeys.includes(app.key));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<nav className="ac-dock">
|
|
|
|
|
|
{apps.map((app) => (
|
|
|
|
|
|
<button key={app.key} className={`${app.key === active ? "active" : ""} ${!hasAccounts && requiresThreadsAccount(app.key) ? "locked" : ""}`} onClick={() => onNavigate(app.key)}>
|
|
|
|
|
|
<AcIcon name={app.icon} size={19} />
|
|
|
|
|
|
<span>{app.label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<button className={active === "settings" ? "active" : ""} onClick={() => onNavigate("settings")}>
|
|
|
|
|
|
<AcIcon name="settings" size={19} />
|
|
|
|
|
|
<span style={{ fontSize: "0.75rem", fontWeight: 800 }}>設定</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={!mobileNavKeys.includes(active) && active !== "settings" ? "active" : ""}
|
|
|
|
|
|
onClick={() => onNavigate("accounts")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<AcIcon name="more" size={19} />
|
|
|
|
|
|
<span style={{ fontSize: "0.75rem", fontWeight: 800 }}>更多</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function requiresThreadsAccount(key: string) {
|
|
|
|
|
|
return key === "publish" || key === "personas" || key === "missions" || key === "patrol";
|
|
|
|
|
|
}
|