feat(web): redesign account settings and the sign-in page

- account settings: sidebar sections (profile, devices, security,
  invitations, sync and sign-in) instead of one long scroll; inline device
  rename, confirmations for destructive actions and toast feedback
- sign-in/register: split layout with a quiet brand column, text tabs and
  underlined inputs; registering asks for the password twice and explains
  that it cannot be recovered
- move invitation accept/label helpers into store/invitations so the
  toast and the settings inbox share them
- add the home page zone styles used by the next commit
This commit is contained in:
justme committed 2026-10-14 22:42:06 +08:00
1 parent 4b8d0b5a5f
commit 0d31e3d058
16 files changed
+882 -454

No files matched your search

+3 -20
View File
@@ -1,27 +1,10 @@
import { useEffect, useRef } from 'react';
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
import { errorMessage } from '@/core-bridge/errors';
import { useChannels } from '@/store/channels';
import { accountApi } from '@/core-bridge/account';
import { acceptInvitation, invitationLabel } from '@/store/invitations';
import { useToast } from '@/store/toast';
import { channelPath, navigate } from './navigation';
const POLL_MS = 8000;
export function invitationLabel(row: AccountInvitation): string {
return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`;
}
async function accept(row: AccountInvitation): Promise<void> {
try {
await accountApi.acceptInvitation(row.channel_id);
await useChannels.getState().refresh();
useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` });
navigate(channelPath(row.channel_id));
} catch (cause) {
useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` });
}
}
/** 登录后轮询账号邀请,出现新邀请就弹通知,可直接接受 */
export function useInvitationNotices(username: string | null): void {
const seen = useRef<Set<string> | null>(null);
@@ -37,7 +20,7 @@ export function useInvitationNotices(username: string | null): void {
const fresh = known ? rows.filter((row) => !known.has(row.channel_id)) : rows;
for (const row of fresh) {
useToast.getState().push({ kind: 'info', text: row.invitedBy ? `${row.invitedBy} 邀请你加入 ${invitationLabel(row)}` : `你收到了加入 ${invitationLabel(row)} 的邀请`,
action: { label: '接受', run: () => { void accept(row); } } });
action: { label: '接受', run: () => { void acceptInvitation(row); } } });
}
} catch { /* 下一轮再试,不打扰用户 */ }
}
-44
View File
@@ -1,44 +0,0 @@
import { useEffect, useState } from 'react';
import { accountApi, type AccountDevice } from '@/core-bridge/account';
import { useAccount } from '@/store/account';
import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
export function AccountDevices() {
const [devices, setDevices] = useState<AccountDevice[]>([]);
const [names, setNames] = useState<Record<string, string>>({});
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const refreshAccount = useAccount((s) => s.refresh);
async function refresh() { setDevices(await accountApi.devices()); }
useEffect(() => { void refresh().catch(() => setError('设备清单读取失败')); }, []);
async function change(device: AccountDevice, revoke: boolean) {
setBusy(true); setError('');
try {
if (revoke) {
await accountApi.revokeDevice(device.id); await refreshAccount();
} else await accountApi.renameDevice(device.id, names[device.id] ?? device.name);
await refresh();
} catch (cause) { setError(cause instanceof Error ? cause.message : '设备更新失败'); }
finally { setBusy(false); }
}
return <section aria-label="账号设备">
<div className={styles.sectionHeader}><strong>我的设备</strong>
<Button variant="ghost" disabled={busy} onClick={() => { void refresh().catch(() => setError('设备清单读取失败')); }}>刷新设备</Button>
</div>
<p className={styles.note}>同一账号可登录多台设备。移除设备只会撤销它的登录,不影响账号在各空间里的成员身份。</p>
{devices.map((device) => <div className={styles.deviceRow} key={device.id}>
<label className={styles.deviceName}>
<input aria-label={`${device.name} 的设备名称`} maxLength={80} value={names[device.id] ?? device.name} onChange={(e) => setNames((values) => ({ ...values, [device.id]: e.target.value }))} />
<span className={styles.deviceType}>{device.current ? '本机' : '其他设备'}</span>
</label>
<div className={styles.deviceActions}>
<Button disabled={busy} onClick={() => { void change(device, false); }}>重命名</Button>
<Button variant="ghost" disabled={busy || device.current} onClick={() => {
if (window.confirm(`移除设备「${device.name}」并撤销它的登录?`)) void change(device, true);
}}>移除设备</Button>
</div>
</div>)}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useAccount } from '@/store/account';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import { AccountPanel } from './AccountPanel';
import { AccountSettings } from './AccountSettings';
import styles from './AccountDock.module.css';
export function AccountDock() {
@@ -24,6 +24,6 @@ export function AccountDock() {
</button>
<Button className={styles.settings} variant="ghost" icon="settings" label="账号设置" onClick={openSettings} />
</section>
{settingsOpen ? <Sheet title="账号设置" wide onClose={() => setSettingsOpen(false)}><AccountPanel variant="settings" /></Sheet> : null}
{settingsOpen ? <Sheet title="账号设置" wide onClose={() => setSettingsOpen(false)}><AccountSettings /></Sheet> : null}
</>;
}
+46 -162
View File
@@ -9,62 +9,12 @@
}
.authPanel {
gap: var(--space-4);
padding: var(--space-6) 32px;
border-radius: var(--radius-lg);
}
.settingsPanel {
gap: 0;
gap: var(--space-6);
padding: 0;
border: 0;
background: transparent;
}
.subsection {
padding-block: var(--space-4);
border-top: 1px solid var(--line);
}
.subsection h3 {
margin: 0 0 var(--space-2);
font-size: 16px;
}
.syncBlock {
padding-bottom: var(--space-4);
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: 40px;
margin-bottom: var(--space-1);
}
.sectionHeader strong {
font-size: 14px;
}
.invitationRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: 48px;
border-bottom: 1px solid var(--line);
font-size: 13px;
}
.avatar {
width: var(--bar-h);
height: var(--bar-h);
object-fit: cover;
border-radius: var(--radius-md);
}
.heading {
display: flex;
align-items: flex-start;
@@ -72,6 +22,15 @@
gap: var(--space-3);
}
/* 登录页的标题与页签重复,只保留给屏幕阅读器(aria-labelledby 仍指向它) */
.quietHeading {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.heading h2 {
margin: 0;
font-size: 16px;
@@ -91,48 +50,29 @@
line-height: 1.6;
}
.settingsPanel .heading {
align-items: center;
padding-bottom: var(--space-4);
}
.settingsPanel .heading h2 {
font-size: 18px;
}
.syncBlock .note {
margin-top: var(--space-2);
}
.user {
color: var(--ok);
font-size: 13px;
}
.form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
}
/* 登录 / 创建账号:文字页签,当前项下划线 */
.authMode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-1);
padding: var(--space-1);
border-radius: var(--radius-md);
background: var(--surface-2);
display: flex;
gap: var(--space-6);
border-bottom: 1px solid var(--line);
}
.authMode button {
min-height: 42px;
padding: 0 var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-sm);
min-height: 40px;
margin-bottom: -1px;
padding: 0;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 14px;
font-size: 15px;
cursor: pointer;
}
@@ -142,14 +82,12 @@
}
.authMode button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent);
color: var(--on-accent);
border-bottom-color: var(--accent);
color: var(--text);
font-weight: 600;
}
.authMode button:not([aria-pressed="true"]):hover {
background: var(--accent-soft);
color: var(--text);
}
@@ -161,19 +99,6 @@
font-size: 13px;
}
.deviceName input {
flex: 1;
min-width: 0;
min-height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 13px;
}
.form input:not([type="checkbox"]) {
min-width: 0;
min-height: 48px;
@@ -194,11 +119,26 @@
accent-color: var(--accent);
}
/* 登录页的输入框:只留一条底线,安静一些 */
.authPanel .form input:not([type="checkbox"]) {
min-height: 44px;
padding-inline: 0;
border: 0;
border-bottom: 1px solid var(--line-strong);
border-radius: 0;
background: transparent;
}
.form input:not([type="checkbox"]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.authPanel .form input:not([type="checkbox"]):focus-visible {
border-bottom-color: var(--accent);
outline: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
@@ -210,6 +150,15 @@
grid-column: 1 / -1;
}
.fieldError {
color: var(--danger);
font-size: 12px;
}
.authPanel .form .actions {
justify-content: center;
}
.authPanel .form .actions > button[type="submit"] {
flex: 1 0 100%;
min-height: 44px;
@@ -223,58 +172,6 @@
min-height: 40px;
}
.avatarField {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.fieldLabel {
color: var(--text-dim);
font-size: 13px;
}
.filePicker {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.fileHint {
color: var(--text-dim);
font-size: 12px;
}
.deviceRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding-block: var(--space-2);
border-bottom: 1px solid var(--line);
}
.deviceName {
display: flex;
flex: 1;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.deviceType {
flex: 0 0 auto;
min-width: 56px;
color: var(--text-dim);
font-size: 12px;
}
.deviceActions {
display: flex;
flex: 0 0 auto;
gap: var(--space-1);
}
.error {
color: var(--danger);
}
@@ -284,21 +181,8 @@
}
@media (width <= 560px) {
.authPanel {
padding: var(--space-4);
}
.form .actions {
grid-column: auto;
}
.deviceRow {
align-items: stretch;
flex-direction: column;
gap: var(--space-2);
}
.deviceActions {
justify-content: flex-end;
}
}
+16 -28
View File
@@ -6,13 +6,10 @@ import { useShares } from '@/store/shares';
import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
import { navigate, channelPath } from '@/app/navigation';
import { InvitationInbox } from './InvitationInbox';
import { PersonalSettings } from './PersonalSettings';
import { AccountDevices } from './AccountDevices';
import { PasskeySettings } from './PasskeySettings';
import { passkeysAvailable } from '@/core-bridge/passkeys';
export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' | 'settings' }) {
/** 未登录时的登录 / 注册 / 解锁。登录后的账号设置见 AccountSettings */
export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' }) {
const account = useAccount((s) => s.username);
const busy = useAccount((s) => s.busy);
const error = useAccount((s) => s.error);
@@ -21,9 +18,6 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
const unlockPasskey = useAccount(s => s.unlockPasskey);
const unlockUsername = useAccount(s => s.unlockUsername);
const cancelPasskey = useAccount(s => s.cancelPasskey);
const logout = useAccount((s) => s.logout);
const sync = useAccount((s) => s.sync);
const remembered = useAccount((s) => s.remembered);
const clearSavedLogin = useAccount((s) => s.clearSavedLogin);
const channelsReady = useChannels((s) => s.hydrated);
const sharesReady = useShares((s) => s.hydrated);
@@ -32,6 +26,7 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
const [register, setRegister] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [message, setMessage] = useState('');
const [remember, setRemember] = useState(true);
const [saved, setSaved] = useState(false);
@@ -46,6 +41,8 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
}).catch(() => { if (active) setMessage('本机保存读取失败,可手动输入登录。'); });
return () => { active = false; };
}, [account]);
const creating = register && !unlockUsername;
const mismatch = creating && confirm !== '' && password !== confirm;
async function clearSaved() {
edited.current = true;
try { await clearSavedLogin(); setSaved(false); setRemember(false); setUsername(''); setPassword(''); setMessage(account ? '本机保存已清除,当前页面仍可使用;刷新后需要重新登录。' : '已清除本机保存的账号和密码。'); }
@@ -55,48 +52,43 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
if (creating && password !== confirm) { setMessage('两次输入的密码不一致'); return; }
setMessage('');
try {
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember);
setPassword('');
setPassword(''); setConfirm('');
await Promise.all([refreshChannels(), refreshShares()]);
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
const pending = useChannels.getState().pendingLink;
if (pending) navigate(channelPath(pending.id));
} catch { /* 错误由账号状态呈现 */ }
finally { setPassword(''); }
finally { setPassword(''); setConfirm(''); }
}
return (
<section className={`${styles.panel} ${variant === 'auth' ? styles.authPanel : ''} ${variant === 'settings' ? styles.settingsPanel : ''}`} aria-labelledby="account-title">
<div className={styles.heading}>
<section className={`${styles.panel} ${variant === 'auth' ? styles.authPanel : ''}`} aria-labelledby="account-title">
<div className={`${styles.heading} ${variant === 'auth' && !unlockUsername ? styles.quietHeading : ''}`}>
<div>
<h2 id="account-title">{variant === 'auth' && !account ? unlockUsername ? '解锁资源保险库' : register ? '创建账号' : '登录账号' : '账号与同步'}</h2>
{variant === 'default' ? <p>登录后可在其他设备恢复本账号保存的空间和分享。</p> : null}
</div>
{account ? <span className={styles.user}>{account}</span> : null}
</div>
{account ? (
<div className={styles.syncBlock}>
<div className={styles.actions}>
<Button disabled={busy} onClick={() => { void sync().then(() => setMessage('资源保险库已同步。')).catch(() => {}); }}>{busy ? '同步中…' : '立即同步'}</Button>
<Button variant="ghost" disabled={busy} onClick={() => { void logout().then(() => navigate('/')); setMessage(''); }}>退出登录</Button>
{remembered ? <Button variant="ghost" disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
</div>
<p className={styles.note}>{remembered ? '已开启本机记住登录。' : '仅本次登录,刷新或关闭页面后需重新登录。'}</p>
</div>
) : (
{account ? null : (
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
{variant === 'auth' && !unlockUsername ? <div className={styles.authMode} role="group" aria-label="选择登录或注册">
<button type="button" aria-pressed={!register} onClick={() => { edited.current = true; setRegister(false); setMessage(''); }}>登录</button>
<button type="button" aria-pressed={!register} onClick={() => { edited.current = true; setRegister(false); setConfirm(''); setMessage(''); }}>登录</button>
<button type="button" aria-pressed={register} onClick={() => { edited.current = true; setRegister(true); setMessage(''); }}>创建账号</button>
</div> : null}
{unlockUsername ? <p className={styles.note}>已用Passkey确认账号,请输入账号密码解锁资源。</p> : null}
<label>用户名<input name="username" autoComplete="username" minLength={3} maxLength={32} value={unlockUsername ?? username} disabled={Boolean(unlockUsername)} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
<label>密码<input name="password" autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => { edited.current = true; setPassword(event.target.value); }} required /></label>
{creating ? <label>确认密码<input name="confirm-password" autoComplete="new-password" type="password" minLength={12} value={confirm} aria-invalid={mismatch} onChange={(event) => { edited.current = true; setConfirm(event.target.value); }} required />
{mismatch ? <span className={styles.fieldError} role="alert">两次输入的密码不一致</span> : null}</label> : null}
{creating ? <p className={styles.note}>密码至少 12 个字符。密码用于在本机加密你的资源保险库,<strong>忘记后无法找回</strong>,请妥善保管。</p> : null}
<label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label>
<div className={styles.actions}>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady || mismatch}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button>
{variant !== 'auth' ? <Button variant="ghost" onClick={() => { edited.current = true; setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button> : null}
{saved ? <Button variant="ghost" disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
{!register && !unlockUsername ? <Button variant="ghost" disabled={busy || !username || !passkeysAvailable()} onClick={() => {
@@ -110,10 +102,6 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
)}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{message ? <p className={styles.message} role="status">{message}</p> : null}
{account ? <div className={styles.subsection}><InvitationInbox /></div> : null}
{account ? <div className={styles.subsection}><PersonalSettings /></div> : null}
{account ? <div className={styles.subsection}><AccountDevices /></div> : null}
{account ? <div className={styles.subsection}><PasskeySettings /></div> : null}
</section>
);
}
@@ -0,0 +1,346 @@
.layout {
display: grid;
grid-template-columns: 208px minmax(0, 1fr);
gap: var(--space-6);
min-height: min(520px, 68dvh);
}
.nav {
position: sticky;
top: 0;
align-self: start;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.who {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
padding: var(--space-2);
}
.avatar,
.avatarLarge {
display: grid;
flex: 0 0 40px;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--radius-avatar);
object-fit: cover;
}
.avatarFallback {
background: var(--surface-2);
color: var(--text-dim);
font-size: 16px;
font-weight: 600;
}
.name {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.name strong,
.name small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name small {
color: var(--text-dim);
font-size: 12px;
}
.tabs {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.tabs button {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 40px;
padding: 0 var(--space-3);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
}
.tabs button:hover {
background: var(--accent-soft);
color: var(--text);
}
.tabs button[aria-selected="true"] {
background: var(--accent-soft);
color: var(--text);
font-weight: 600;
}
.tabs button span {
flex: 1;
white-space: nowrap;
}
.tabs em {
min-width: 20px;
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
background: var(--danger);
color: var(--on-accent);
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 20px;
text-align: center;
}
.content {
min-width: 0;
}
.page {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.page h2 {
margin: 0;
font-size: 18px;
}
.page h3 {
margin: var(--space-2) 0 0;
font-size: 14px;
}
.hint {
margin: 0;
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
}
.field input {
min-width: 0;
min-height: 44px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 14px;
}
.readonly {
color: var(--text);
font-family: var(--font-mono);
font-size: 14px;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.avatarRow {
display: flex;
align-items: center;
gap: var(--space-4);
}
.avatarLarge {
flex-basis: 72px;
width: 72px;
height: 72px;
font-size: 28px;
}
.avatarSide {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.footer {
display: flex;
justify-content: flex-end;
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius-md);
list-style: none;
}
.list:empty {
display: none;
}
.item {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 56px;
padding: var(--space-2) var(--space-3);
}
.item + .item {
border-top: 1px solid var(--line);
}
.item > svg {
flex-shrink: 0;
color: var(--text-dim);
}
.itemMain {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
line-height: 1.4;
}
.itemMain strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.itemMain small {
color: var(--text-dim);
font-size: 12px;
}
.itemMain input {
min-height: 36px;
padding: 0 var(--space-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 14px;
}
.itemMain input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.field input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.badge {
margin-inline-start: var(--space-2);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
background: var(--ok-soft);
color: var(--ok);
font-size: 11px;
font-style: normal;
font-weight: 400;
}
.inline {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.group {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--line);
}
.groupText {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.groupText small {
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.danger {
color: var(--danger);
}
@media (width <= 720px) {
.layout {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
min-height: 0;
}
.nav {
position: static;
flex-direction: row;
align-items: center;
gap: var(--space-2);
overflow-x: auto;
}
.who {
display: none;
}
.tabs {
flex-direction: row;
}
.group {
align-items: flex-start;
flex-direction: column;
gap: var(--space-2);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
import { useAccount } from '@/store/account';
import { Icon, type IconName } from '@/ui/Icon';
import { DeviceSettings } from './DeviceSettings';
import { InvitationInbox } from './InvitationInbox';
import { ProfileSettings } from './ProfileSettings';
import { SecuritySettings } from './SecuritySettings';
import { SyncSettings } from './SyncSettings';
import styles from './AccountSettings.module.css';
type Tab = 'profile' | 'devices' | 'security' | 'invites' | 'sync';
const TABS: { id: Tab; label: string; icon: IconName }[] = [
{ id: 'profile', label: '个人资料', icon: 'edit' },
{ id: 'devices', label: '我的设备', icon: 'desktop' },
{ id: 'security', label: '安全', icon: 'lock' },
{ id: 'invites', label: '空间邀请', icon: 'bell' },
{ id: 'sync', label: '同步与登录', icon: 'history' },
];
/** 账号设置:左侧导航,右侧一次只显示一块。手机上导航变成顶部横向页签 */
export function AccountSettings() {
const username = useAccount((s) => s.username) ?? '';
const personal = useAccount((s) => s.personal);
const [tab, setTab] = useState<Tab>('profile');
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
const displayName = personal?.displayName || username;
const refreshInvitations = useCallback(async () => {
try { setInvitations(await accountApi.invitations()); } catch { /* 邀请页自己会显示读取失败 */ }
}, []);
useEffect(() => { void refreshInvitations(); }, [refreshInvitations]);
return <div className={styles.layout}>
<nav className={styles.nav} aria-label="账号设置分类">
<div className={styles.who}>
{personal?.avatar ? <img className={styles.avatar} src={personal.avatar} alt="" />
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(displayName)[0]?.toLocaleUpperCase() ?? '?'}</span>}
<span className={styles.name}><strong>{displayName}</strong>{displayName !== username ? <small>{username}</small> : null}</span>
</div>
<div className={styles.tabs} role="tablist" aria-orientation="vertical">
{TABS.map((item) => <button key={item.id} type="button" role="tab" aria-selected={tab === item.id} onClick={() => setTab(item.id)}>
<Icon name={item.icon} size={18} /><span>{item.label}</span>
{item.id === 'invites' && invitations.length ? <em aria-label={`${invitations.length} 个待处理`}>{invitations.length}</em> : null}
</button>)}
</div>
</nav>
<section className={styles.content} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
{tab === 'profile' ? <ProfileSettings /> : null}
{tab === 'devices' ? <DeviceSettings /> : null}
{tab === 'security' ? <SecuritySettings /> : null}
{tab === 'invites' ? <InvitationInbox invitations={invitations} onChange={() => { void refreshInvitations(); }} /> : null}
{tab === 'sync' ? <SyncSettings /> : null}
</section>
</div>;
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState, type KeyboardEvent } from 'react';
import { accountApi, type AccountDevice } from '@/core-bridge/account';
import { errorMessage } from '@/core-bridge/errors';
import { formatDate, formatAgo } from '@/lib/format';
import { useAccount } from '@/store/account';
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import styles from './AccountSettings.module.css';
/** last_seen 是 Unix 秒;一天内显示「N 分钟前」,更早显示日期 */
function seenLabel(seconds: number): string {
const ts = seconds < 1e12 ? seconds * 1000 : seconds;
return Date.now() - ts < 24 * 3600_000 ? formatAgo(ts) : formatDate(ts);
}
export function DeviceSettings() {
const refreshAccount = useAccount((s) => s.refresh);
const [devices, setDevices] = useState<AccountDevice[]>([]);
const [loaded, setLoaded] = useState(false);
const [editing, setEditing] = useState<string>();
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast.getState().push;
async function refresh() {
try { setDevices(await accountApi.devices()); } catch { toast({ kind: 'error', text: '设备清单读取失败' }); }
finally { setLoaded(true); }
}
useEffect(() => { void refresh(); }, []);
async function run(action: () => Promise<void>, ok: string) {
setBusy(true);
try { await action(); toast({ kind: 'ok', text: ok }); await refresh(); }
catch (cause) { toast({ kind: 'error', text: `设备更新失败:${errorMessage(cause)}` }); }
finally { setBusy(false); }
}
function begin(device: AccountDevice) { setEditing(device.id); setDraft(device.name); }
function save(device: AccountDevice) {
const name = draft.trim();
setEditing(undefined);
if (!name || name === device.name) return;
void run(() => accountApi.renameDevice(device.id, name), '设备名称已更新');
}
function keyDown(event: KeyboardEvent, device: AccountDevice) {
if (event.key === 'Enter') { event.preventDefault(); save(device); }
if (event.key === 'Escape') setEditing(undefined);
}
function remove(device: AccountDevice) {
if (!window.confirm(`移除设备「${device.name}」并撤销它的登录?`)) return;
void run(async () => { await accountApi.revokeDevice(device.id); await refreshAccount(); }, '设备已移除');
}
return <section className={styles.page} aria-label="我的设备">
<h2>我的设备</h2>
<p className={styles.hint}>同一账号可登录多台设备。移除设备只会撤销它的登录,不影响账号在各空间里的成员身份。</p>
<ul className={styles.list}>
{devices.map((device) => <li className={styles.item} key={device.id}>
<Icon name="desktop" size={20} />
<div className={styles.itemMain}>
{editing === device.id
? <input autoFocus maxLength={80} aria-label={`${device.name} 的设备名称`} value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={() => save(device)} onKeyDown={(event) => keyDown(event, device)} />
: <strong>{device.name}{device.current ? <em className={styles.badge}>本机</em> : null}</strong>}
<small>{device.current ? '正在使用' : `最近使用 ${seenLabel(device.last_seen)}`}</small>
</div>
<Button variant="ghost" icon="edit" label={`重命名 ${device.name}`} disabled={busy} onMouseDown={(event) => event.preventDefault()} onClick={() => begin(device)} />
{device.current ? null : <Button variant="ghost" icon="trash" label={`移除设备 ${device.name}`} disabled={busy} onClick={() => remove(device)} />}
</li>)}
</ul>
{loaded && !devices.length ? <p className={styles.hint}>暂无设备。</p> : null}
</section>;
}
+108 -84
View File
@@ -41,43 +41,85 @@
.dashboard {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
align-items: start;
gap: var(--space-6);
width: min(100%, 1480px);
row-gap: var(--space-6);
width: min(100%, 1180px);
max-width: none;
padding: 32px var(--space-6) 96px;
}
.dashboard.hasShares {
grid-template-columns: minmax(0, 1.12fr) minmax(360px, 0.88fr);
}
.dashboardHead {
grid-column: 1 / -1;
padding: 0;
text-align: left;
}
.primaryColumn {
/* 便捷分享 / 空间:两个并列的分区,中间一条细线 */
.zone {
display: flex;
flex-direction: column;
gap: var(--space-6);
gap: var(--space-4);
min-width: 0;
padding-inline: var(--space-6);
}
.dashboard:not(.hasShares) .primaryColumn {
width: 100%;
max-width: 1120px;
margin-inline: auto;
.zone:first-of-type {
padding-inline-start: 0;
}
.secondaryColumn {
min-width: 0;
padding-inline-start: var(--space-4);
.zone + .zone {
padding-inline-end: 0;
border-inline-start: 1px solid var(--line);
}
.zoneHead {
display: flex;
align-items: center;
gap: var(--space-3);
}
.zoneIcon {
display: grid;
flex: 0 0 36px;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--text-dim);
}
.zoneTitle {
margin: 0;
font-size: 18px;
}
.zoneHint {
margin: 2px 0 0;
color: var(--text-dim);
font-size: 13px;
}
.zoneBadge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-inline-start: auto;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--ok-soft);
color: var(--ok);
font-size: 12px;
white-space: nowrap;
}
.empty {
margin: 0;
color: var(--text-dim);
font-size: 13px;
}
.head {
padding: var(--space-4) 0 var(--space-2);
text-align: center;
@@ -108,25 +150,6 @@
font-size: 14px;
}
.channels {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.sectionHead h2 {
margin: 0;
font-size: 18px;
}
.sectionHead p {
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 13px;
}
.actions {
display: flex;
gap: var(--space-2);
@@ -136,19 +159,11 @@
flex: 1;
}
.note {
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 12px;
text-align: center;
}
.authMain {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: clamp(40px, 7vw, 96px);
width: min(100%, 1120px);
width: min(100%, 1040px);
max-width: none;
min-height: 100%;
padding: 48px 32px;
@@ -157,25 +172,38 @@
.authIntro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
padding-inline-start: var(--space-4);
align-items: flex-end;
gap: var(--space-3);
padding-inline-end: clamp(32px, 7vw, 96px);
text-align: right;
}
.authLogo {
margin: 0;
font-size: clamp(40px, 4vw, 56px);
font-weight: 650;
letter-spacing: -1.5px;
line-height: 1.1;
font-size: clamp(44px, 5vw, 64px);
font-weight: 600;
letter-spacing: -2px;
line-height: 1.05;
}
.authIntro p {
max-width: 34ch;
max-width: 28ch;
margin: 0;
color: var(--text-dim);
font-size: 16px;
line-height: 1.7;
font-size: 14px;
line-height: 1.8;
}
/* 右侧表单与左侧品牌之间一条细线分隔 */
.authMain > section {
min-height: 360px;
padding-inline-start: clamp(32px, 7vw, 96px);
border-inline-start: 1px solid var(--line);
}
.authMain > section > * {
width: 100%;
max-width: 380px;
}
@media (width >= 900px) {
@@ -187,10 +215,6 @@
padding-top: 32px;
}
.authMain {
padding-top: 48px;
}
.head {
padding-bottom: var(--space-4);
}
@@ -202,46 +226,46 @@
width: min(100%, 760px);
}
.dashboard.hasShares {
grid-template-columns: minmax(0, 1fr);
}
.dashboardHead {
grid-column: auto;
}
.secondaryColumn {
padding: var(--space-4) 0 0;
.zone {
padding-inline: 0;
}
.zone + .zone {
padding-top: var(--space-6);
border-top: 1px solid var(--line);
border-inline-start: 0;
}
}
@media (width <= 760px) {
.authMain {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
width: min(100%, 520px);
padding: 72px var(--space-4) var(--space-6);
}
.authIntro {
align-items: flex-start;
padding-inline-end: 0;
text-align: left;
}
.authMain > section {
min-height: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
.dashboard {
gap: var(--space-4);
padding: var(--space-6) var(--space-3) 88px;
}
.authMain {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
width: min(100%, 560px);
padding: 72px var(--space-4) var(--space-6);
}
.authIntro {
gap: var(--space-2);
padding-inline: var(--space-2);
}
.authLogo {
font-size: 40px;
}
.authIntro p {
font-size: 14px;
}
}
.error {
+29 -23
View File
@@ -1,27 +1,33 @@
import { useEffect, useState } from 'react';
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
import { useChannels } from '@/store/channels';
import { useState } from 'react';
import type { AccountInvitation } from '@/core-bridge/account';
import { acceptInvitation, invitationLabel } from '@/store/invitations';
import { formatDate } from '@/lib/format';
import { Button } from '@/ui/Button';
import { channelPath, navigate } from '@/app/navigation';
import styles from './AccountPanel.module.css';
export function InvitationInbox() {
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
const [error, setError] = useState('');
import { Icon } from '@/ui/Icon';
import styles from './AccountSettings.module.css';
export function InvitationInbox({ invitations, onChange }: { invitations: AccountInvitation[]; onChange: () => void }) {
const [busy, setBusy] = useState<string>();
useEffect(() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }, []);
async function accept(id: string) {
setBusy(id); setError('');
try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); navigate(channelPath(id)); }
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
finally { setBusy(undefined); }
async function accept(row: AccountInvitation) {
setBusy(row.channel_id);
try { await acceptInvitation(row); } finally { setBusy(undefined); onChange(); }
}
return <div>
<div className={styles.sectionHeader}><strong>空间邀请</strong>
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }}>刷新</Button>
</div>
{invitations.length === 0 ? <p className={styles.note}>暂无邀请。</p> : invitations.map((row) => <div className={styles.invitationRow} key={row.channel_id}>
<span>{row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`}{row.invitedBy ? ` · 邀请人 ${row.invitedBy}` : ''}</span><Button disabled={Boolean(busy)} onClick={() => { void accept(row.channel_id); }}>{busy === row.channel_id ? '加入中…' : '接受邀请'}</Button>
</div>)}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</div>;
return <section className={styles.page} aria-label="空间邀请">
<h2>空间邀请</h2>
<p className={styles.hint}>其他账号邀请你加入的空间。接受后会进入该空间,并按空间设置获得权限。</p>
<ul className={styles.list}>
{invitations.map((row) => <li className={styles.item} key={row.channel_id}>
<Icon name="bell" size={20} />
<div className={styles.itemMain}>
<strong>{invitationLabel(row)}</strong>
<small>{row.invitedBy ? `${row.invitedBy} 邀请 · ` : ''}{formatDate(row.created_at < 1e12 ? row.created_at * 1000 : row.created_at)}</small>
</div>
<Button variant="primary" disabled={Boolean(busy)} onClick={() => { void accept(row); }}>{busy === row.channel_id ? '加入中…' : '接受'}</Button>
</li>)}
</ul>
{!invitations.length ? <p className={styles.hint}>暂无待处理的邀请。</p> : null}
<div><Button variant="ghost" disabled={Boolean(busy)} onClick={onChange}>刷新</Button></div>
</section>;
}
-39
View File
@@ -1,39 +0,0 @@
import { useEffect, useState, type FormEvent } from 'react';
import { bindPasskey, passkeyApi, passkeysAvailable } from '@/core-bridge/passkeys';
import type { PasskeyInfoBody } from '@/core-bridge/generated/api';
import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
export function PasskeySettings() {
const [items, setItems] = useState<PasskeyInfoBody[]>([]);
const [name, setName] = useState('我的Passkey');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => { let active = true; void passkeyApi.list().then(value => { if (active) setItems(value); }).catch(() => { if (active) setMessage('Passkey列表读取失败'); }); return () => { active = false; }; }, []);
async function bind(event: FormEvent) {
event.preventDefault(); if (busy) return;
setBusy(true); setMessage('');
try { await bindPasskey(password, name); setItems(await passkeyApi.list()); setMessage('Passkey已绑定。支持恢复的凭据可在其他设备上解锁账号。'); }
catch (error) { setMessage(error instanceof Error && /[\u4e00-\u9fff]/.test(error.message) ? error.message : 'Passkey操作已取消或不可用,请重试'); }
finally { setPassword(''); setBusy(false); }
}
async function remove(id: string) {
setBusy(true); setMessage('');
try { await passkeyApi.remove(id); setItems(await passkeyApi.list()); setMessage('Passkey已撤销'); }
catch { setMessage('Passkey撤销失败'); } finally { setBusy(false); }
}
return <section aria-label="Passkey设置">
<h3>Passkey</h3>
<p className={styles.note}>可使用设备解锁或密码管理器登录。支持解密恢复的Passkey可在忘记密码时解锁资源;仅登录的Passkey仍需账号密码解密。</p>
{items.map(item => <div className={styles.actions} key={item.id}><span>{item.name} · {item.can_recover ? '登录与解密恢复' : '仅登录'}</span>
<Button variant="ghost" disabled={busy} onClick={() => { void remove(item.id); }}>撤销 {item.name}</Button></div>)}
<form className={styles.form} onSubmit={event => { void bind(event); }}>
<label>Passkey名称<input value={name} maxLength={80} onChange={event => setName(event.target.value)} required /></label>
<label>验证账号密码<input type="password" autoComplete="current-password" value={password} onChange={event => setPassword(event.target.value)} required /></label>
<Button type="submit" disabled={busy || !passkeysAvailable()}>{busy ? '验证中…' : '绑定Passkey'}</Button>
</form>
{!passkeysAvailable() ? <p className={styles.note}>当前环境不支持Passkey,请使用支持的浏览器和HTTPS或localhost地址。</p> : null}
{message ? <p className={styles.note} role="status">{message}</p> : null}
</section>;
}
@@ -1,52 +0,0 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useAccount } from '@/store/account';
import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
export function PersonalSettings() {
const personal = useAccount((s) => s.personal);
const username = useAccount((s) => s.username);
const busy = useAccount((s) => s.busy);
const update = useAccount((s) => s.updatePersonal);
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(personal?.displayName ?? '');
const [avatar, setAvatar] = useState(personal?.avatar);
const [message, setMessage] = useState('');
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
async function choose(file?: File) {
if (!file) return;
setMessage('');
try {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 250_000) throw new Error('请选择 250 KB 以内的 PNG、JPEG 或 WebP 图片');
const bitmap = await createImageBitmap(file);
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096;
bitmap.close(); if (!valid) throw new Error('头像宽高不能超过 4096 像素');
const value = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('头像读取失败')); reader.readAsDataURL(file);
});
setAvatar(value);
} catch (cause) { setMessage(cause instanceof Error ? cause.message : '头像读取失败'); }
}
async function submit(event: FormEvent) {
event.preventDefault(); setMessage('');
try { await update(name, avatar); setMessage('个人资料已保存并同步。'); }
catch (cause) { setMessage(cause instanceof Error ? cause.message : '保存失败'); }
}
return <form className={styles.form} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
<label>个人名称<input maxLength={48} value={name} placeholder={username ?? ''} onChange={(e) => setName(e.target.value)} /></label>
<div className={styles.avatarField}>
<span className={styles.fieldLabel}>头像</span>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp" aria-label="选择头像文件" hidden onChange={(e) => { void choose(e.target.files?.[0]); e.target.value = ''; }} />
<div className={styles.filePicker}>
<Button icon="image" onClick={() => fileInput.current?.click()}>选择头像</Button>
<span className={styles.fileHint}>PNG、JPEG 或 WebP,最大 250 KB</span>
</div>
</div>
<div className={styles.actions}>{avatar ? <img className={styles.avatar} src={avatar} alt="个人头像" /> : null}
<Button type="submit" disabled={busy}>保存个人资料</Button>
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除头像</Button> : null}
</div>
<p className={styles.note}>用户名是固定的账号身份。个人名称与头像由服务端保存并在设备间同步,设备名称单独管理。</p>
{message ? <p className={styles.note} role="status">{message}</p> : null}
</form>;
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { errorMessage } from '@/core-bridge/errors';
import { useAccount } from '@/store/account';
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import styles from './AccountSettings.module.css';
const MAX_AVATAR_BYTES = 250_000;
async function readAvatar(file: File): Promise<string> {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_AVATAR_BYTES) throw new Error('请选择 250 KB 以内的 PNG、JPEG 或 WebP 图片');
const bitmap = await createImageBitmap(file);
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096;
bitmap.close();
if (!valid) throw new Error('头像宽高不能超过 4096 像素');
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('头像读取失败'));
reader.readAsDataURL(file);
});
}
export function ProfileSettings() {
const personal = useAccount((s) => s.personal);
const username = useAccount((s) => s.username) ?? '';
const busy = useAccount((s) => s.busy);
const update = useAccount((s) => s.updatePersonal);
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(personal?.displayName ?? '');
const [avatar, setAvatar] = useState(personal?.avatar);
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
const changed = name.trim() !== (personal?.displayName ?? '') || avatar !== personal?.avatar;
const shown = name.trim() || username;
async function choose(file?: File) {
if (!file) return;
try { setAvatar(await readAvatar(file)); }
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!changed || busy) return;
try { await update(name.trim(), avatar); useToast.getState().push({ kind: 'ok', text: '个人资料已保存并同步' }); }
catch (cause) { useToast.getState().push({ kind: 'error', text: `保存失败:${errorMessage(cause)}` }); }
}
return <form className={styles.page} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
<h2>个人资料</h2>
<div className={styles.avatarRow}>
{avatar ? <img className={styles.avatarLarge} src={avatar} alt="个人头像" />
: <span className={`${styles.avatarLarge} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(shown)[0]?.toLocaleUpperCase() ?? '?'}</span>}
<div className={styles.avatarSide}>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
<div className={styles.row}>
<Button icon="image" onClick={() => fileInput.current?.click()}>更换头像</Button>
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
</div>
<span className={styles.hint}>PNG、JPEG 或 WebP,最大 250 KB</span>
</div>
</div>
<label className={styles.field}>个人名称
<input maxLength={48} value={name} placeholder={username} onChange={(event) => setName(event.target.value)} />
</label>
<div className={styles.field}>用户名
<span className={styles.readonly}>{username}</span>
<span className={styles.hint}>用户名是固定的账号身份,不能修改。个人名称与头像由服务端保存,在你的所有设备上同步。</span>
</div>
<div className={styles.footer}><Button type="submit" variant="primary" disabled={!changed || busy}>{busy ? '保存中…' : '保存'}</Button></div>
</form>;
}
@@ -0,0 +1,68 @@
import { useEffect, useState, type FormEvent } from 'react';
import { bindPasskey, passkeyApi, passkeysAvailable } from '@/core-bridge/passkeys';
import type { PasskeyInfoBody } from '@/core-bridge/generated/api';
import { formatDate } from '@/lib/format';
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import styles from './AccountSettings.module.css';
export function SecuritySettings() {
const [items, setItems] = useState<PasskeyInfoBody[]>([]);
const [adding, setAdding] = useState(false);
const [name, setName] = useState('我的 Passkey');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const available = passkeysAvailable();
const toast = useToast.getState().push;
useEffect(() => {
let active = true;
void passkeyApi.list().then((value) => { if (active) setItems(value); }).catch(() => toast({ kind: 'error', text: 'Passkey 列表读取失败' }));
return () => { active = false; };
}, [toast]);
async function bind(event: FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true);
try {
await bindPasskey(password, name);
setItems(await passkeyApi.list());
setAdding(false);
toast({ kind: 'ok', text: 'Passkey 已绑定' });
} catch (error) {
toast({ kind: 'error', text: error instanceof Error && /[一-鿿]/.test(error.message) ? error.message : 'Passkey 操作已取消或不可用,请重试' });
} finally { setPassword(''); setBusy(false); }
}
async function remove(item: PasskeyInfoBody) {
if (!window.confirm(`撤销 Passkey「${item.name}」?`)) return;
setBusy(true);
try { await passkeyApi.remove(item.id); setItems(await passkeyApi.list()); toast({ kind: 'ok', text: 'Passkey 已撤销' }); }
catch { toast({ kind: 'error', text: 'Passkey 撤销失败' }); }
finally { setBusy(false); }
}
return <section className={styles.page} aria-label="安全">
<h2>安全</h2>
<h3>Passkey</h3>
<p className={styles.hint}>可用设备解锁或密码管理器登录。支持解密恢复的 Passkey 能在忘记密码时解锁资源;仅登录的 Passkey 仍需账号密码解密。忘记密码且没有可恢复的 Passkey 时,资源无法找回。</p>
<ul className={styles.list}>
{items.map((item) => <li className={styles.item} key={item.id}>
<Icon name="lock" size={20} />
<div className={styles.itemMain}><strong>{item.name}</strong><small>{item.can_recover ? '登录与解密恢复' : '仅登录'} · 添加于 {formatDate(item.created_at * (item.created_at < 1e12 ? 1000 : 1))}</small></div>
<Button variant="ghost" icon="trash" label={`撤销 ${item.name}`} disabled={busy} onClick={() => { void remove(item); }} />
</li>)}
</ul>
{!items.length ? <p className={styles.hint}>还没有绑定 Passkey。</p> : null}
{adding ? <form className={styles.inline} onSubmit={(event) => { void bind(event); }}>
<label className={styles.field}>名称<input value={name} maxLength={80} onChange={(event) => setName(event.target.value)} required /></label>
<label className={styles.field}>验证账号密码<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
<div className={styles.row}>
<Button type="submit" variant="primary" disabled={busy}>{busy ? '验证中…' : '绑定'}</Button>
<Button variant="ghost" disabled={busy} onClick={() => setAdding(false)}>取消</Button>
</div>
</form> : <div><Button icon="plus" disabled={!available} onClick={() => setAdding(true)}>绑定 Passkey</Button></div>}
{!available ? <p className={styles.hint}>当前环境不支持 Passkey,请使用支持的浏览器,并通过 HTTPS 或 localhost 访问。</p> : null}
</section>;
}
+43
View File
@@ -0,0 +1,43 @@
import { navigate } from '@/app/navigation';
import { errorMessage } from '@/core-bridge/errors';
import { useAccount } from '@/store/account';
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import styles from './AccountSettings.module.css';
export function SyncSettings() {
const busy = useAccount((s) => s.busy);
const remembered = useAccount((s) => s.remembered);
const sync = useAccount((s) => s.sync);
const logout = useAccount((s) => s.logout);
const clearSavedLogin = useAccount((s) => s.clearSavedLogin);
const toast = useToast.getState().push;
async function syncNow() {
try { await sync(); toast({ kind: 'ok', text: '资源保险库已同步' }); }
catch (cause) { toast({ kind: 'error', text: `同步失败:${errorMessage(cause)}` }); }
}
async function clearSaved() {
if (!window.confirm('清除本机保存的账号和密码?当前页面仍可使用,刷新后需要重新登录。')) return;
try { await clearSavedLogin(); toast({ kind: 'ok', text: '已清除本机保存的登录信息' }); }
catch (cause) { toast({ kind: 'error', text: errorMessage(cause) }); }
}
async function signOut() {
if (!window.confirm('退出登录?')) return;
await logout();
navigate('/');
}
return <section className={styles.page} aria-label="同步与登录">
<h2>同步与登录</h2>
<div className={styles.group}>
<div className={styles.groupText}><strong>资源保险库</strong><small>空间与分享的访问凭据在本机加密后同步,换设备登录即可恢复。</small></div>
<Button disabled={busy} onClick={() => { void syncNow(); }}>{busy ? '同步中…' : '立即同步'}</Button>
</div>
<div className={styles.group}>
<div className={styles.groupText}><strong>本机记住登录</strong><small>{remembered ? '已开启:刷新页面后自动恢复登录。只在自己的设备上使用。' : '未开启:刷新或关闭页面后需要重新登录。'}</small></div>
{remembered ? <Button disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
</div>
<div className={styles.footer}><Button className={styles.danger} variant="ghost" disabled={busy} onClick={() => { void signOut(); }}>退出登录</Button></div>
</section>;
}
+21
View File
@@ -0,0 +1,21 @@
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
import { errorMessage } from '@/core-bridge/errors';
import { channelPath, navigate } from '@/app/navigation';
import { useChannels } from './channels';
import { useToast } from './toast';
export function invitationLabel(row: AccountInvitation): string {
return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`;
}
/** 接受邀请并进入空间;结果用 toast 反馈,不抛出 */
export async function acceptInvitation(row: AccountInvitation): Promise<void> {
try {
await accountApi.acceptInvitation(row.channel_id);
await useChannels.getState().refresh();
useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` });
navigate(channelPath(row.channel_id));
} catch (cause) {
useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` });
}
}