Files
WebDrop/web/src/features/home/AccountDevices.tsx
T
justme 4b8d0b5a5f
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
- chat: left-aligned discord-style messages with grouped senders, hover
  actions, an auto-growing composer and file cards
- send: render messages and files locally first and replace the
  placeholder when the worker publishes the real item; failures keep the
  text with a retry action
- navigation: space rail plus a flatter channel list with the space name
  header and collapsible groups
- members: right pane tabs (files / members) grouped by online, offline
  and pending users, with a prominent invite action and a top bar shortcut
- notifications: toast host for invite results; invitees get a toast with
  an accept action, and the invitation payload now carries the space name
  and inviter inside the encrypted grant
- users over devices: drop device names, the device list and device
  rename/release; senders show the account (guests show as guest) and the
  share panel only keeps links
- follow contract 7.0.0 (max_members, members_used, members_full, /join)
- docs: ui-design and frontend updated for the new layout and concepts
2026-10-14 22:19:25 +08:00

45 lines
2.5 KiB
TypeScript

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>;
}