feat(web): add account workspaces and signed content flows
This commit is contained in:
1 parent
7b8d61d1ba
commit
b5cf3d5958
117 files changed
+2789
-1674
No files matched your search
@@ -22,17 +22,23 @@ export function AccountDevices() {
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : '设备更新失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section aria-label="账号设备"><strong>我的设备</strong>
|
||||
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.actions} key={device.id}>
|
||||
<input aria-label={`${device.name} 的设备名称`} maxLength={80} value={names[device.id] ?? device.name} onChange={(e) => setNames((values) => ({ ...values, [device.id]: e.target.value }))} />
|
||||
<span>{device.current ? '本机' : '其他设备'}</span>
|
||||
<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>
|
||||
{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>)}
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void refresh().catch(() => setError('设备清单读取失败')); }}>刷新设备</Button>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
.dock {
|
||||
position: fixed;
|
||||
z-index: 24;
|
||||
left: max(var(--space-4), env(safe-area-inset-left));
|
||||
bottom: max(var(--space-4), env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: min(288px, calc(100vw - var(--space-6)));
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile:hover,
|
||||
.profile:focus-visible {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.profile:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
flex: 0 0 36px;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profileText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profileName,
|
||||
.username {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings {
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.dock {
|
||||
left: max(var(--space-2), env(safe-area-inset-left));
|
||||
bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
gap: var(--space-1);
|
||||
width: auto;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.profile {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.avatarFallback {
|
||||
flex-basis: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.profileText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings {
|
||||
flex-basis: 40px;
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { AccountPanel } from './AccountPanel';
|
||||
import styles from './AccountDock.module.css';
|
||||
|
||||
export function AccountDock() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const username = useAccount((s) => s.username);
|
||||
const personal = useAccount((s) => s.personal);
|
||||
if (!username) return null;
|
||||
|
||||
const displayName = personal?.displayName || username;
|
||||
const subtitle = personal?.displayName && personal.displayName !== username ? username : '已登录';
|
||||
const initial = Array.from(displayName)[0]?.toLocaleUpperCase() ?? '?';
|
||||
const openSettings = () => setSettingsOpen(true);
|
||||
|
||||
return <>
|
||||
<section className={styles.dock} aria-label="当前账号">
|
||||
<button className={styles.profile} type="button" onClick={openSettings} aria-label={`打开 ${displayName} 的账号设置`}>
|
||||
{personal?.avatar ? <img className={styles.avatar} src={personal.avatar} alt="" /> : <span className={styles.avatarFallback}>{initial}</span>}
|
||||
<span className={styles.profileText}><strong className={styles.profileName}>{displayName}</strong><span className={styles.username}>{subtitle}</span></span>
|
||||
</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}
|
||||
</>;
|
||||
}
|
||||
@@ -4,9 +4,60 @@
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.authPanel {
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) 32px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
gap: 0;
|
||||
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);
|
||||
@@ -26,6 +77,10 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.authPanel .heading h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.heading p,
|
||||
.note,
|
||||
.message,
|
||||
@@ -36,15 +91,66 @@
|
||||
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-3);
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.authMode button {
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.authMode button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.authMode button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.authMode button:not([aria-pressed="true"]):hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form label {
|
||||
@@ -52,11 +158,25 @@
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form input {
|
||||
.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;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -66,6 +186,19 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form .remember input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.form input:not([type="checkbox"]):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -77,16 +210,69 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.authPanel .form .actions > button[type="submit"] {
|
||||
flex: 1 0 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.form .remember {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.form .remember input {
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
.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 {
|
||||
@@ -98,11 +284,21 @@
|
||||
}
|
||||
|
||||
@media (width <= 560px) {
|
||||
.form {
|
||||
grid-template-columns: 1fr;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,18 @@ 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() {
|
||||
export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' | 'settings' }) {
|
||||
const account = useAccount((s) => s.username);
|
||||
const busy = useAccount((s) => s.busy);
|
||||
const error = useAccount((s) => s.error);
|
||||
const login = useAccount((s) => s.login);
|
||||
const passkeyLogin = useAccount(s => s.passkeyLogin);
|
||||
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);
|
||||
@@ -51,7 +57,7 @@ export function AccountPanel() {
|
||||
if (busy) return;
|
||||
setMessage('');
|
||||
try {
|
||||
await login(username, password, register, remember);
|
||||
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember);
|
||||
setPassword('');
|
||||
await Promise.all([refreshChannels(), refreshShares()]);
|
||||
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
|
||||
@@ -62,40 +68,52 @@ export function AccountPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.panel} aria-labelledby="account-title">
|
||||
<section className={`${styles.panel} ${variant === 'auth' ? styles.authPanel : ''} ${variant === 'settings' ? styles.settingsPanel : ''}`} aria-labelledby="account-title">
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<h2 id="account-title">账号与同步</h2>
|
||||
<p>登录后可在其他设备恢复本账号保存的空间和分享。</p>
|
||||
<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.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 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>
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>用户名<input name="username" autoComplete="username" minLength={3} maxLength={32} value={username} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
|
||||
{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(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>
|
||||
<label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label>
|
||||
<p className={styles.note}>仅在自己的设备使用。退出登录后仍可自动填充;“清除本机保存”会删除已保存资料。</p>
|
||||
<div className={styles.actions}>
|
||||
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : register ? '创建账号' : '登录并同步'}</Button>
|
||||
<Button variant="ghost" onClick={() => { edited.current = true; setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{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={() => {
|
||||
edited.current = true; setPassword(''); void passkeyLogin(username, remember).then(() => {
|
||||
const pending = useChannels.getState().pendingLink; if (pending && useAccount.getState().username) navigate(channelPath(pending.id));
|
||||
}).catch(() => {});
|
||||
}}>使用Passkey登录</Button> : null}
|
||||
{unlockUsername ? <Button variant="ghost" onClick={() => { void cancelPasskey(); }}>取消解锁</Button> : null}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{message ? <p className={styles.message} role="status">{message}</p> : null}
|
||||
{account ? <InvitationInbox /> : null}
|
||||
{account ? <PersonalSettings /> : null}
|
||||
{account ? <AccountDevices /> : null}
|
||||
<p className={styles.note}>资源清单在浏览器加密后再同步,服务端无法读取空间链接。密码没有找回功能;忘记密码将无法解密账号保险库。</p>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import { AccountPanel } from './AccountPanel';
|
||||
import { ThemeMenu } from '@/ui/ThemeMenu';
|
||||
import styles from './Home.module.css';
|
||||
export function AccountScreen() {
|
||||
return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={styles.main}>
|
||||
<header className={styles.head}><h1 className={styles.logo}>WebDrop</h1><p className={styles.sub}>注册或登录后,管理你的空间和分享。</p></header>
|
||||
<AccountPanel />
|
||||
return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={`${styles.main} ${styles.authMain}`}>
|
||||
<header className={styles.authIntro}>
|
||||
<h1 className={styles.authLogo}>WebDrop</h1>
|
||||
<p>登录后,可在其他设备恢复本账号保存的空间和分享。</p>
|
||||
</header>
|
||||
<AccountPanel variant="auth" />
|
||||
</main></div>;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
<button type="button" className={styles.open} onClick={() => onOpen(c)}>
|
||||
<span className={styles.id}>{'state' in c ? c.id.slice(0, 8) : c.name || c.id.slice(0, 8)}…</span>
|
||||
<span className={styles.meta}>
|
||||
{c.dataSource === 'cache' ? <span>离线缓存</span> : null}
|
||||
{'state' in c ? (
|
||||
<>
|
||||
<span className={styles.enc}><Icon name="lock" size={12} /> 加密</span>
|
||||
@@ -45,7 +46,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
|
||||
</span>
|
||||
<span>{c.role === 'owner' ? '所有者' : '成员'}</span>
|
||||
<span>{c.seats} 台设备</span>
|
||||
<span>最多 {c.seats} 人</span>
|
||||
<span>{formatAgo(c.createdAt)}创建</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,35 +6,39 @@ import { Sheet } from '@/ui/Sheet';
|
||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import styles from './CreateSheet.module.css';
|
||||
|
||||
const SEATS = [2, 3, 5, 10];
|
||||
const DOWNLOAD_LIMITS = [0, 1, 3, 5, 10, 20];
|
||||
const TTLS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
{ label: '7 天', value: 604800 },
|
||||
];
|
||||
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import { creationOptions } from '@/core-bridge/settings';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
import { ttlLabel } from '@/lib/settingOptions';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: (c: ChannelInfo) => void;
|
||||
}
|
||||
|
||||
/** 新建空间:协议 §12 里与创建相关的设置子集 */
|
||||
export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
export function CreateSheet(props: Props) {
|
||||
const { info, error, load } = useServerSettings();
|
||||
return info ? <CreateForm {...props} info={info} /> : <Sheet title="新建空间" onClose={props.onClose}>
|
||||
<p role={error ? 'alert' : 'status'}>{error ?? '正在读取服务端设置…'}</p>
|
||||
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}
|
||||
</Sheet>;
|
||||
}
|
||||
function CreateForm({ onClose, onCreated, info }: Props & { info: ServerInfoBody }) {
|
||||
const create = useChannels((s) => s.create);
|
||||
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 10, ttlSeconds: 86400, membersCanUpload: true, fileMaxDownloads: 10, joinPolicy: 'invite', allowGuests: false });
|
||||
const [opts, setOpts] = useState<CreateOptions>(() => creationOptions(info.defaults));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [failed, setFailed] = useState('');
|
||||
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
setFailed('');
|
||||
try {
|
||||
const captchaToken = await solveCreationCaptcha();
|
||||
onCreated(await create(opts, captchaToken));
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} catch (cause) {
|
||||
setFailed(errorMessage(cause));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +48,7 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span>空间名称(加入者和服务端可见)</span>
|
||||
<input type="text" maxLength={48} value={opts.name ?? ''} placeholder="例如:工作设备" onChange={(e) => set('name', e.target.value)} />
|
||||
<input type="text" value={opts.name ?? ''} placeholder="例如:工作设备" onChange={(e) => set('name', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className={styles.check}><input type="checkbox" checked={opts.mode === 'enc'} onChange={(event) => set('mode', event.target.checked ? 'enc' : 'raw')} /><span>启用端到端加密</span></label>
|
||||
@@ -52,23 +56,23 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
<p className={styles.secure}>空间属于当前账号,并自动创建“常规”频道。默认通过账号邀请加入,成员在自己的设备上自动领取密钥包。</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>设备数(席位)</span>
|
||||
<span>成员人数上限</span>
|
||||
<select value={opts.seats} onChange={(e) => set('seats', Number(e.target.value))}>
|
||||
{SEATS.map((n) => <option key={n} value={n}>{n} 台</option>)}
|
||||
{info.choices.seats.map((n) => <option key={n} value={n}>{n} 人</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>内容保留时间</span>
|
||||
<select value={opts.ttlSeconds} onChange={(e) => set('ttlSeconds', Number(e.target.value))}>
|
||||
{TTLS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
{info.choices.ttl_seconds.map(value => <option key={value} value={value}>{ttlLabel(value)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>每个文件的受限下载次数</span>
|
||||
<select value={opts.fileMaxDownloads ?? 10} onChange={(e) => set('fileMaxDownloads', Number(e.target.value))}>
|
||||
{DOWNLOAD_LIMITS.map((n) => <option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>)}
|
||||
<select value={opts.fileMaxDownloads} onChange={(e) => set('fileMaxDownloads', Number(e.target.value))}>
|
||||
{info.choices.file_max_downloads.map((n) => <option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -77,7 +81,7 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
<span>允许成员上传</span>
|
||||
</label>
|
||||
|
||||
{failed ? <p className={styles.warn} role="alert">创建失败,请重试。</p> : null}
|
||||
{failed ? <p className={styles.warn} role="alert">{failed}</p> : null}
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>{busy ? '创建中…' : '创建'}</Button>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
}
|
||||
|
||||
.buildInfo {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
right: max(var(--space-4), env(safe-area-inset-right));
|
||||
bottom: max(var(--space-3), env(safe-area-inset-bottom));
|
||||
position: static;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
justify-content: center;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
max-width: calc(100vw - var(--space-6));
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
@@ -40,11 +39,55 @@
|
||||
padding: var(--space-6) var(--space-4) calc(var(--space-6) + 32px);
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-6);
|
||||
width: min(100%, 1480px);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard:not(.hasShares) .primaryColumn {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.secondaryColumn {
|
||||
min-width: 0;
|
||||
padding-inline-start: var(--space-4);
|
||||
border-inline-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head {
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.head.dashboardHead {
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
@@ -56,6 +99,15 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.dashboardHead .logo {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.dashboardHead .sub {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -91,16 +143,107 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.authMain {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
align-items: center;
|
||||
gap: clamp(40px, 7vw, 96px);
|
||||
width: min(100%, 1120px);
|
||||
max-width: none;
|
||||
min-height: 100%;
|
||||
padding: 48px 32px;
|
||||
}
|
||||
|
||||
.authIntro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding-inline-start: var(--space-4);
|
||||
}
|
||||
|
||||
.authLogo {
|
||||
margin: 0;
|
||||
font-size: clamp(40px, 4vw, 56px);
|
||||
font-weight: 650;
|
||||
letter-spacing: -1.5px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.authIntro p {
|
||||
max-width: 34ch;
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (width >= 900px) {
|
||||
.main {
|
||||
padding-top: 8vh;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.authMain {
|
||||
padding-top: 48px;
|
||||
}
|
||||
|
||||
.head {
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.dashboard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: min(100%, 760px);
|
||||
}
|
||||
|
||||
.dashboard.hasShares {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dashboardHead {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.secondaryColumn {
|
||||
padding: var(--space-4) 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.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 {
|
||||
margin: calc(var(--space-3) * -1) 0 0;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -4,14 +4,12 @@ import { buildInfo } from '@/lib/buildInfo';
|
||||
import type { ChannelInfo, Share } from '@/core-bridge/types';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ThemeMenu } from '@/ui/ThemeMenu';
|
||||
import { ChannelList } from './ChannelList';
|
||||
import { AccountPanel } from './AccountPanel';
|
||||
import { CreateSheet } from './CreateSheet';
|
||||
import { QuickShare } from './QuickShare';
|
||||
import styles from './Home.module.css';
|
||||
@@ -21,8 +19,7 @@ const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({
|
||||
/** 初始状态:什么都没创建时看到的页面。核心是「快速分享」 */
|
||||
export function Home() {
|
||||
const createDraft = useShares((s) => s.createDraft);
|
||||
const loadDemo = useChannels((s) => s.loadDemo);
|
||||
const loadShareDemo = useShares((s) => s.loadDemo);
|
||||
const shares = useShares((s) => s.list);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -62,43 +59,40 @@ export function Home() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.appearance}><ThemeMenu /></div>
|
||||
<main className={styles.main}>
|
||||
<header className={styles.head}>
|
||||
<main className={`${styles.main} ${styles.dashboard} ${shares.length ? styles.hasShares : ''}`}>
|
||||
<header className={`${styles.head} ${styles.dashboardHead}`}>
|
||||
<h1 className={styles.logo}>WebDrop</h1>
|
||||
<p className={styles.sub}>在自己的设备之间快速传文件和文本,也可以分享给别人。</p>
|
||||
</header>
|
||||
|
||||
<QuickShare
|
||||
busy={busy}
|
||||
text={shareText}
|
||||
files={shareFiles}
|
||||
onTextChange={setShareText}
|
||||
onFilesChange={setShareFiles}
|
||||
onShare={share}
|
||||
/>
|
||||
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
|
||||
<div className={styles.primaryColumn}>
|
||||
<QuickShare
|
||||
busy={busy}
|
||||
text={shareText}
|
||||
files={shareFiles}
|
||||
onTextChange={setShareText}
|
||||
onFilesChange={setShareFiles}
|
||||
onShare={share}
|
||||
/>
|
||||
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
|
||||
|
||||
<AccountPanel />
|
||||
<section className={styles.channels} aria-labelledby="channels-title">
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="channels-title">空间工作区</h2>
|
||||
<p>同一账号跨设备使用,也可邀请其他账号共同使用频道与文件。</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建空间</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button>
|
||||
</div>
|
||||
|
||||
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
||||
<ChannelList kind="channel" title="我的空间" onOpen={open} />
|
||||
<p className={styles.note}>空间密钥按账号加密分发,登录其他设备后可自动恢复。已授权成员可读取空间历史。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className={styles.channels} aria-labelledby="channels-title">
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="channels-title">空间工作区</h2>
|
||||
<p>同一账号跨设备使用,也可邀请其他账号共同使用频道与文件。</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建空间</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button>
|
||||
</div>
|
||||
{shares.length ? <aside className={styles.secondaryColumn} aria-label="我的分享"><ChannelList kind="share" title="我的分享" onOpen={open} /></aside> : null}
|
||||
|
||||
<ChannelList kind="channel" title="我的空间" onOpen={open} />
|
||||
<p className={styles.note}>空间密钥按账号加密分发,登录其他设备后可自动恢复。已授权成员可读取空间历史。</p>
|
||||
</section>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<aside className={styles.buildInfo} aria-label="构建信息">
|
||||
|
||||
@@ -15,9 +15,11 @@ export function InvitationInbox() {
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
|
||||
finally { setBusy(undefined); }
|
||||
}
|
||||
return <div><strong>空间邀请</strong>
|
||||
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }}>刷新邀请</Button>
|
||||
{invitations.length === 0 ? <p className={styles.note}>暂无邀请。</p> : invitations.map((row) => <div className={styles.actions} key={row.channel_id}>
|
||||
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.channel_id.slice(0, 8)}…</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}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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,4 +1,4 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './AccountPanel.module.css';
|
||||
@@ -8,6 +8,7 @@ export function PersonalSettings() {
|
||||
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('');
|
||||
@@ -33,12 +34,19 @@ export function PersonalSettings() {
|
||||
}
|
||||
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>
|
||||
<label>头像<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(e) => { void choose(e.target.files?.[0]); 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>
|
||||
<p className={styles.note}>用户名是固定的账号身份。个人名称与头像由服务端保存并在设备间同步,设备名称单独管理。</p>
|
||||
{message ? <p className={styles.note} role="status">{message}</p> : null}
|
||||
</form>;
|
||||
}
|
||||
Reference in new issue
Block a user