Files
browser/platform_web/pages/ProfileSettingsPage.tsx
T
2026-07-15 19:43:06 +08:00

267 lines
12 KiB
TypeScript

import { ArrowLeft, LogOut, MoonStar, Palette, Sparkles, Upload, UserRoundPen, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import type { PageComponentProps } from "../contracts/page";
import { defaultPageForUser } from "../routes/routes";
import {
applyBackgroundImage,
applyThemeBackgroundPreset,
applyThemePalette,
loadThemeState,
persistBackgroundImage,
persistThemeBackgroundPreset,
persistThemePalette,
themeBackgroundPresets,
themePalettes,
type ThemeBackgroundId,
type ThemePaletteId,
type WorkspaceThemeState
} from "../theme/tokens";
import { cx } from "../utils/classes";
import { PageFrame } from "../components/PageFrame";
import { ResultBadge } from "../components/StateViews";
type SaveState = { status: "pending" | "succeeded" | "failed"; label: string };
export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSave, onThemePreferenceSave }: PageComponentProps) {
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
const [profileResult, setProfileResult] = useState<SaveState>();
const [themeResult, setThemeResult] = useState<SaveState>();
const [profileDraft, setProfileDraft] = useState({
displayName: session.displayName,
avatarUrl: session.profile.avatarUrl ?? "",
phone: session.profile.phone ?? "",
qq: session.profile.qq ?? "",
contactNote: session.profile.contactNote ?? ""
});
useEffect(() => {
setProfileDraft({
displayName: session.displayName,
avatarUrl: session.profile.avatarUrl ?? "",
phone: session.profile.phone ?? "",
qq: session.profile.qq ?? "",
contactNote: session.profile.contactNote ?? ""
});
}, [session]);
const activePalette = useMemo(() => themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0], [themeState.paletteId]);
const activeBackground = useMemo(() => themeBackgroundPresets.find((preset) => preset.id === themeState.backgroundPresetId) ?? themeBackgroundPresets[0], [themeState.backgroundPresetId]);
const hasCustomBackground = Boolean(themeState.backgroundImage);
const backgroundMetricValue = hasCustomBackground ? "自定义背景" : activeBackground.label;
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setProfileResult({ status: "pending", label: "正在保存个人资料" });
try {
const saved = await onProfileSave({
displayName: profileDraft.displayName.trim(),
avatarUrl: profileDraft.avatarUrl.trim(),
phone: profileDraft.phone.trim(),
qq: profileDraft.qq.trim(),
contactNote: profileDraft.contactNote.trim()
});
setProfileResult({ status: saved.source === "api" ? "succeeded" : "failed", label: saved.source === "api" ? "个人资料已保存到数据库" : "个人资料 API 暂不可用,已保留在本地会话" });
} catch (error) {
setProfileResult({ status: "failed", label: error instanceof Error ? error.message : "个人资料保存失败" });
}
}
async function persistThemePreference(nextState: WorkspaceThemeState) {
setThemeResult({ status: "pending", label: "正在保存界面偏好" });
try {
const response = await onThemePreferenceSave({
paletteId: nextState.paletteId,
backgroundPresetId: nextState.backgroundPresetId,
backgroundImage: nextState.backgroundImage
});
setThemeResult({ status: response.persistence === "api" ? "succeeded" : "failed", label: response.persistence === "api" ? "界面偏好已保存到数据库" : "界面偏好 API 暂不可用,已本地保存" });
} catch (error) {
setThemeResult({ status: "failed", label: error instanceof Error ? error.message : "界面偏好保存失败" });
}
}
function selectPalette(paletteId: ThemePaletteId) {
applyThemePalette(paletteId);
persistThemePalette(paletteId);
setThemeState((current) => {
const nextState = { ...current, paletteId };
void persistThemePreference(nextState);
return nextState;
});
}
function selectBackgroundPreset(backgroundPresetId: ThemeBackgroundId) {
applyThemeBackgroundPreset(backgroundPresetId);
persistThemeBackgroundPreset(backgroundPresetId);
setThemeState((current) => {
const nextState = { ...current, backgroundPresetId };
void persistThemePreference(nextState);
return nextState;
});
}
function handleBackgroundUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : null;
applyBackgroundImage(dataUrl);
persistBackgroundImage(dataUrl);
setThemeState((current) => {
const nextState = { ...current, backgroundImage: dataUrl };
void persistThemePreference(nextState);
return nextState;
});
};
reader.readAsDataURL(file);
}
function clearBackground() {
applyBackgroundImage(null);
persistBackgroundImage(null);
setThemeState((current) => {
const nextState = { ...current, backgroundImage: null };
void persistThemePreference(nextState);
return nextState;
});
}
return (
<div className="console-page profile-settings-page">
<PageFrame
kicker="账号"
title="个人设置"
status={session.source === "api" ? "账号 API 已连接" : "本地会话"}
metrics={[
{ label: "身份", value: session.roles.length ? String(session.roles.length) : "0", tone: "neutral" },
{ label: "配色", value: activePalette.label, tone: "success" },
{ label: "背景", value: backgroundMetricValue, tone: "warning" }
]}
/>
<section className="console-panel profile-settings-hero" aria-label="account summary">
<div className="profile-settings-avatar" aria-hidden="true">{profileDraft.avatarUrl ? <img src={profileDraft.avatarUrl} alt="" /> : <UserRoundPen size={30} />}</div>
<div>
<h2>{profileDraft.displayName || session.displayName}</h2>
<p>{session.email ?? session.id}</p>
<span className="page-status">{session.status}</span>
</div>
<div className="profile-settings-actions">
<button type="button" className="theme-upload" onClick={() => onNavigate(defaultPageForUser(session))}>
<ArrowLeft size={14} />
<span>返回工作台</span>
</button>
<button type="button" className="theme-upload" onClick={() => void onLogout()}>
<LogOut size={14} />
<span>退出登录</span>
</button>
</div>
</section>
<section className="profile-settings-grid">
<form className="console-panel profile-settings-form" onSubmit={saveProfile}>
<div className="panel-header">
<h2>个人资料</h2>
{profileResult && <ResultBadge status={profileResult.status} label={profileResult.label} />}
</div>
<label>
<span>昵称</span>
<input value={profileDraft.displayName} required onChange={(event) => setProfileDraft((current) => ({ ...current, displayName: event.target.value }))} />
</label>
<label>
<span>头像 URL</span>
<input value={profileDraft.avatarUrl} onChange={(event) => setProfileDraft((current) => ({ ...current, avatarUrl: event.target.value }))} />
</label>
<div className="profile-settings-form-row">
<label>
<span>手机号</span>
<input value={profileDraft.phone} inputMode="tel" onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))} />
</label>
<label>
<span>QQ</span>
<input value={profileDraft.qq} inputMode="numeric" onChange={(event) => setProfileDraft((current) => ({ ...current, qq: event.target.value }))} />
</label>
</div>
<label>
<span>联系方式备注</span>
<textarea value={profileDraft.contactNote} rows={4} onChange={(event) => setProfileDraft((current) => ({ ...current, contactNote: event.target.value }))} />
</label>
<button type="submit" className="profile-save-button">
<Sparkles size={14} />
<span>保存资料</span>
</button>
</form>
<section className="console-panel profile-settings-form" aria-label="theme preferences">
<div className="panel-header">
<h2>界面偏好</h2>
{themeResult && <ResultBadge status={themeResult.status} label={themeResult.label} />}
</div>
<div className="profile-panel-title">
<Palette size={15} />
<strong>主题配色</strong>
</div>
<div className="palette-grid profile-palette-grid">
{themePalettes.map((palette) => (
<button key={palette.id} type="button" className={cx("palette-option", palette.id === themeState.paletteId && "palette-option-active")} aria-pressed={palette.id === themeState.paletteId} title={palette.summary} onClick={() => selectPalette(palette.id)}>
<span className="palette-swatch-row" aria-hidden="true">
{palette.swatches.map((swatch) => (
<span key={swatch} style={{ background: swatch }} />
))}
</span>
<span className="palette-option-label">{palette.id === themeState.paletteId ? <Sparkles size={13} /> : <MoonStar size={13} />}{palette.label}</span>
</button>
))}
</div>
<div className="profile-panel-title">
<MoonStar size={15} />
<strong>背景桌面</strong>
</div>
<div className="background-preset-grid profile-background-grid">
{themeBackgroundPresets.map((preset) => {
const isFallbackPreset = preset.id === themeState.backgroundPresetId;
const isVisiblePreset = isFallbackPreset && !hasCustomBackground;
return (
<button
key={preset.id}
type="button"
className={cx("background-preset-option", isVisiblePreset && "background-preset-option-active", hasCustomBackground && isFallbackPreset && "background-preset-option-fallback")}
aria-pressed={isVisiblePreset}
title={hasCustomBackground ? `${preset.summary},当前自定义背景正在显示;此预设会在移除上传背景后显示` : preset.summary}
onClick={() => selectBackgroundPreset(preset.id)}
>
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" />
<span className="background-preset-label">
{isVisiblePreset ? <Sparkles size={13} /> : <MoonStar size={13} />}
{preset.label}
{hasCustomBackground && isFallbackPreset && <span className="background-preset-fallback-badge">备用</span>}
</span>
</button>
);
})}
</div>
<div className="theme-background-actions">
<label className="theme-upload" title="上传自定义背景桌面">
<Upload size={14} />
<span>上传背景</span>
<input type="file" accept="image/*" style={{ display: "none" }} onChange={handleBackgroundUpload} />
</label>
{themeState.backgroundImage && (
<button type="button" className="theme-upload" title="移除自定义背景" onClick={clearBackground}>
<X size={14} />
<span>移除背景</span>
</button>
)}
</div>
<span className="theme-background-note">{themeState.backgroundImage ? `当前显示自定义上传背景;${activeBackground.label} 仅作为移除上传后的备用桌面。` : "当前使用内置背景桌面。"}</span>
</section>
</section>
</div>
);
}