feat(web): add spaces, grouped channels and file chat
This commit is contained in:
1 parent
c7d8ef15bc
commit
f6cfb12716
111 files changed
+4371
-506
No files matched your search
@@ -0,0 +1,38 @@
|
||||
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="账号设备"><strong>我的设备</strong>
|
||||
<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>
|
||||
</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,96 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: var(--bar-h);
|
||||
height: var(--bar-h);
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.heading p,
|
||||
.note,
|
||||
.message,
|
||||
.error {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.user {
|
||||
color: var(--ok);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form input {
|
||||
min-width: 0;
|
||||
padding: var(--space-2) 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;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form .actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
@media (width <= 560px) {
|
||||
.form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form .actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useChannels } from '@/store/channels';
|
||||
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';
|
||||
|
||||
export function AccountPanel() {
|
||||
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 logout = useAccount((s) => s.logout);
|
||||
const sync = useAccount((s) => s.sync);
|
||||
const channelsReady = useChannels((s) => s.hydrated);
|
||||
const sharesReady = useShares((s) => s.hydrated);
|
||||
const refreshChannels = useChannels((s) => s.refresh);
|
||||
const refreshShares = useShares((s) => s.refresh);
|
||||
const [register, setRegister] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setMessage('');
|
||||
try {
|
||||
await login(username, password, register);
|
||||
setPassword('');
|
||||
await Promise.all([refreshChannels(), refreshShares()]);
|
||||
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
|
||||
const pending = useChannels.getState().pendingLink;
|
||||
if (pending) navigate(channelPath(pending.id));
|
||||
} catch { /* 错误由账号状态呈现 */ }
|
||||
finally { setPassword(''); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.panel} aria-labelledby="account-title">
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<h2 id="account-title">账号与同步</h2>
|
||||
<p>登录后可在其他设备恢复本账号保存的空间和分享。</p>
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>用户名<input autoComplete="username" minLength={3} maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
||||
<label>密码<input autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
<div className={styles.actions}>
|
||||
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : register ? '创建账号' : '登录并同步'}</Button>
|
||||
<Button variant="ghost" onClick={() => { setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button>
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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 />
|
||||
</main></div>;
|
||||
}
|
||||
@@ -36,8 +36,15 @@
|
||||
}
|
||||
|
||||
.id {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channelId {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.meta {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
{list.map((c) => (
|
||||
<li key={c.id} className={styles.row}>
|
||||
<button type="button" className={styles.open} onClick={() => onOpen(c)}>
|
||||
<span className={styles.id}>{c.id.slice(0, 8)}…</span>
|
||||
<span className={styles.id}>{'state' in c ? c.id.slice(0, 8) : c.name || c.id.slice(0, 8)}…</span>
|
||||
<span className={styles.meta}>
|
||||
{'state' in c ? (
|
||||
<>
|
||||
@@ -40,6 +40,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{c.name ? <span className={styles.channelId}>{c.id.slice(0, 8)}…</span> : null}
|
||||
<span className={c.mode === 'enc' ? styles.enc : styles.raw}>
|
||||
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
|
||||
</span>
|
||||
@@ -56,7 +57,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
<Button variant="ghost" onClick={() => setConfirmId(undefined)}>取消</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" icon="trash" label={kind === 'share' ? '从本机移除' : '从本机移除(不会删除频道)'} onClick={() => setConfirmId(c.id)} />
|
||||
<Button variant="ghost" icon="trash" label="从当前资源清单移除(不会删除服务端内容)" onClick={() => setConfirmId(c.id)} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -14,12 +14,8 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field legend {
|
||||
padding: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.field select {
|
||||
.field select,
|
||||
.field input[type="text"] {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
@@ -28,25 +24,10 @@
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.seg button {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg button[aria-checked="true"] {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
.secure {
|
||||
margin: 0;
|
||||
color: var(--ok);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.check {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 },
|
||||
@@ -18,10 +19,10 @@ interface Props {
|
||||
onCreated: (c: ChannelInfo) => void;
|
||||
}
|
||||
|
||||
/** 新建频道:协议 §12 里与创建相关的设置子集 */
|
||||
/** 新建空间:协议 §12 里与创建相关的设置子集 */
|
||||
export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
const create = useChannels((s) => s.create);
|
||||
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true });
|
||||
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 10, ttlSeconds: 86400, membersCanUpload: true, fileMaxDownloads: 10, joinPolicy: 'invite', allowGuests: false });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||
@@ -39,16 +40,16 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet title="新建频道" onClose={onClose}>
|
||||
<Sheet title="新建空间" onClose={onClose}>
|
||||
<div className={styles.form}>
|
||||
<fieldset className={styles.field}>
|
||||
<legend>模式</legend>
|
||||
<div className={styles.seg} role="radiogroup">
|
||||
<button type="button" role="radio" aria-checked={opts.mode === 'enc'} onClick={() => set('mode', 'enc')}>加密(推荐)</button>
|
||||
<button type="button" role="radio" aria-checked={opts.mode === 'raw'} onClick={() => set('mode', 'raw')}>明文</button>
|
||||
</div>
|
||||
{opts.mode === 'raw' ? <p className={styles.warn}>明文频道里的内容服务端可见,创建后不能改成加密。</p> : null}
|
||||
</fieldset>
|
||||
<label className={styles.field}>
|
||||
<span>空间名称(加入者和服务端可见)</span>
|
||||
<input type="text" maxLength={48} 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>
|
||||
<p className={opts.mode === 'enc' ? styles.secure : styles.warn}>{opts.mode === 'enc' ? '内容和文件名在设备端加密。' : '未加密的内容、文件名和频道信息对服务端可见。'}创建后不能改变加密方式。</p>
|
||||
<p className={styles.secure}>空间属于当前账号,并自动创建“常规”频道。默认通过账号邀请加入,成员在自己的设备上自动领取密钥包。</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>设备数(席位)</span>
|
||||
@@ -64,6 +65,13 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
</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>
|
||||
</label>
|
||||
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={opts.membersCanUpload} onChange={(e) => set('membersCanUpload', e.target.checked)} />
|
||||
<span>允许成员上传</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -77,20 +78,22 @@ export function Home() {
|
||||
/>
|
||||
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
|
||||
|
||||
<AccountPanel />
|
||||
|
||||
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
||||
|
||||
<section className={styles.channels} aria-labelledby="channels-title">
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="channels-title">设备频道</h2>
|
||||
<p>多台自己的设备之间长期同步</p>
|
||||
<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>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建空间</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button>
|
||||
</div>
|
||||
|
||||
<ChannelList kind="channel" title="我的频道" onOpen={open} />
|
||||
<p className={styles.note}>加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。</p>
|
||||
<ChannelList kind="channel" title="我的空间" onOpen={open} />
|
||||
<p className={styles.note}>空间密钥按账号加密分发,登录其他设备后可自动恢复。已授权成员可读取空间历史。</p>
|
||||
</section>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
|
||||
import { useChannels } from '@/store/channels';
|
||||
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('');
|
||||
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); }
|
||||
}
|
||||
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}>
|
||||
<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}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, 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 [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>
|
||||
<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.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>;
|
||||
}
|
||||
Reference in new issue
Block a user