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:
1 parent
4b8d0b5a5f
commit
0d31e3d058
16 files changed
+881
-453
No files matched your search
@@ -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 { /* 下一轮再试,不打扰用户 */ }
|
||||
}
|
||||
|
||||
@@ -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,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}
|
||||
</>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
{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>;
|
||||
<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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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)}` });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user