6 Commits
Author SHA1 Message Date
justme 55f1b70d89 feat(web): crop avatars in the browser with a live message preview [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 17:54:32 +08:00
justme 18c871ec85 feat(web): use the Material settings gear and add rotate icons 2026-10-16 17:54:32 +08:00
justme 18185c5bd9 refactor(web): drop the settings entry from the space header 2026-10-16 17:54:32 +08:00
justme b847916a5b feat(web): group space settings into basic, content and join tabs 2026-10-16 17:54:31 +08:00
justme 79bfd560cb feat(web): call the login name the account ID and list sign-up requirements 2026-10-16 17:54:31 +08:00
justme 700be2ebae fix(storage): retry a read transaction that fails to begin under load 2026-10-16 17:54:30 +08:00
18 changed files with 582 additions and 100 deletions

No files matched your search

@@ -50,12 +50,22 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
}
public T Read<T>(Func<IResourceTx, T> action)
{
for (int attempt = 0; ; attempt++)
{
using var conn = Open();
// 显式事务让一次 Read 内的多条查询看到同一份快照
// 读事务延迟到首次访问时才取快照,避免并发读预检抢占 SQLite 写锁。
using var tx = conn.BeginTransaction(deferred: true);
return action(new SqliteTx(conn));
SqliteTransaction tx;
try { tx = conn.BeginTransaction(deferred: true); }
// 并发压力下偶发 SQLITE_ERROR(CI 的并行追加测试里出现过):丢弃这条连接和池里的闲置连接,换新连接重试
catch (SqliteException e) when (e.SqliteErrorCode == 1 && attempt < 2)
{
SqliteConnection.ClearPool(conn);
continue;
}
using (tx) return action(new SqliteTx(conn));
}
}
public T Write<T>(Func<IResourceTx, T> action)
+2 -2
View File
@@ -8,8 +8,8 @@ const messages: Record<string, string> = {
captcha_unavailable: '验证码服务暂时不可用,请稍后重试。',
api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。',
mode_mismatch: '空间加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。',
username_unavailable: '这个用户名已被使用。', vault_conflict: '账号资源在其他设备上刚刚更新,请再次同步。',
account_invalid: '用户名或密码错误,或登录已过期。', account_required: '请先登录账号。',
username_unavailable: '这个账号 ID 已被使用。', vault_conflict: '账号资源在其他设备上刚刚更新,请再次同步。',
account_invalid: '账号 ID 或密码错误,或登录已过期。', account_required: '请先登录账号。',
invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。',
channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。',
space_name_required: '请填写空间名称。',
+1 -1
View File
@@ -35,7 +35,7 @@ export function unlockedAccount() {
let restoration: Promise<void> | undefined;
export function usernameOf(input: string): string {
const username = input.trim().toLowerCase();
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线');
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('账号 ID 需为 3–32 位字母、数字、下划线或短横线');
return username;
}
function headers(session: Pick<AccountSession, 'token'>): Record<string, string> { return { 'X-WD-Account': session.token }; }
@@ -15,10 +15,72 @@
color: var(--text);
}
.form {
.layout {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
gap: var(--space-6);
height: min(360px, 70dvh);
}
.tabs {
display: flex;
flex-direction: column;
align-self: start;
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;
}
.content {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
min-height: 0;
}
.pane {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-4);
overflow-y: auto;
}
.check {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 36px;
}
.check input {
accent-color: var(--accent);
}
.field {
@@ -41,24 +103,35 @@
font: inherit;
}
.check {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 36px;
}
.check input {
accent-color: var(--accent);
}
.field input:disabled,
.field select:disabled {
opacity: 0.7;
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
@media (width <= 720px) {
.layout {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
height: auto;
}
.tabs {
flex-direction: row;
overflow-x: auto;
}
}
.note {
margin: 0;
margin: calc(-1 * var(--space-2)) 0 0;
color: var(--text-dim);
font-size: 12px;
}
@@ -1,6 +1,7 @@
import { useState } from 'react';
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Icon, type IconName } from '@/ui/Icon';
import { Sheet } from '@/ui/Sheet';
import styles from './ChannelSettingsSheet.module.css';
@@ -8,6 +9,13 @@ import { useServerSettings } from '@/store/server';
import type { ServerInfoBody } from '@/core-bridge/generated/api';
import { ttlLabel, withCurrent } from '@/lib/settingOptions';
type Tab = 'basic' | 'content' | 'join';
const TABS: { id: Tab; label: string; icon: IconName }[] = [
{ id: 'basic', label: '基本', icon: 'edit' },
{ id: 'content', label: '内容', icon: 'history' },
{ id: 'join', label: '加入', icon: 'users' },
];
interface Props {
channel: ChannelInfo;
onClose: () => void;
@@ -31,6 +39,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!);
const [joinPassword, setJoinPassword] = useState('');
const [clearPassword, setClearPassword] = useState(false);
const [tab, setTab] = useState<Tab>('basic');
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds);
@@ -51,57 +60,60 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
}
}
const dirty = name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|| ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword;
const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8);
return (
<Sheet title="空间设置" onClose={onClose}>
<div className={styles.form}>
<label className={styles.field}>
<span>空间名称</span>
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} />
<span className={styles.note}>空间名称和设置对加入者及服务端可见。</span>
</label>
<label className={styles.field}>
<span>成员人数上限</span>
<input type="number" min={1} max={info.limits.max_members} step={1} value={maxMembers} disabled={!owner} onChange={(event) => setMaxMembers(Number(event.target.value))} />
</label>
<label className={styles.field}>
<span>内容保留时间</span>
<Sheet title="空间设置" onClose={onClose} wide>
<div className={styles.layout}>
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类">
{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></button>)}
</div>
<div className={styles.content}>
<section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
{tab === 'basic' ? <>
<label className={styles.field}><span>空间名称</span>
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} /></label>
<p className={styles.note}>空间名称和设置对加入者及服务端可见。</p>
<label className={styles.field}><span>成员人数上限</span>
<input type="number" min={1} max={info.limits.max_members} step={1} value={maxMembers} disabled={!owner} onChange={(event) => setMaxMembers(Number(event.target.value))} /></label>
<p className={styles.note}>成员与邀请请在右侧栏的「成员」页管理。</p>
</> : null}
{tab === 'content' ? <>
<label className={styles.field}><span>内容保留时间</span>
<select value={ttlSeconds} disabled={!owner} onChange={(event) => setTtlSeconds(Number(event.target.value))}>
{ttlOptions.map(value => <option key={value} value={value}>{ttlLabel(value)}</option>)}
</select>
</label>
<label className={styles.field}>
<span>受限文件下载次数</span>
{ttlOptions.map((value) => <option key={value} value={value}>{ttlLabel(value)}</option>)}
</select></label>
<label className={styles.field}><span>受限文件下载次数</span>
<select value={fileMaxDownloads} disabled={!owner} onChange={(event) => setFileMaxDownloads(Number(event.target.value))}>
{downloadOptions.map((value) => <option key={value} value={value}>{value === 0 ? '不限' : `${value} 次`}</option>)}
</select>
</label>
</select></label>
<label className={styles.check}>
<input type="checkbox" checked={membersCanUpload} disabled={!owner} onChange={(event) => setMembersCanUpload(event.target.checked)} />
<span>允许成员上传</span>
</label>
<span>允许成员上传</span></label>
</> : null}
{tab === 'join' ? <>
<label className={styles.field}><span>加入方式</span>
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
<option value="open">登录后持链接加入</option><option value="invite">仅受邀账号加入</option>
</select>
</label>
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
</label>
{owner && channel.passwordRequired ? <label className={styles.check}><input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除空间加入密码</span></label> : null}
<p className={styles.note}>成员与邀请请在右侧栏的「成员」页管理。</p>
</select></label>
<label className={styles.field}><span>{channel.passwordRequired ? '更换加入密码' : '加入密码(可选)'}</span>
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} /></label>
{owner && channel.passwordRequired ? <label className={styles.check}>
<input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除加入密码</span></label> : null}
<p className={styles.note}>空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。</p>
</> : null}
</section>
{owner ? (
<>
<div className={styles.footer}>
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
<Button variant="primary" disabled={saving || maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
</>
<Button variant="primary" disabled={saving || !dirty || invalid} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button>
</div>
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
</div>
</div>
</Sheet>
);
}
+2 -2
View File
@@ -30,8 +30,8 @@ export function InviteSheet({ onClose, onInvited }: { onClose: () => void; onInv
return <Sheet title="邀请成员" onClose={() => { if (!busy) onClose(); }} placement="top">
<form className={styles.invite} onSubmit={(event) => { void submit(event); }}>
<label>用户名
<input autoFocus maxLength={32} value={username} disabled={busy} placeholder="已注册用户的用户名" onChange={(event) => setUsername(event.target.value)} />
<label>账号 ID
<input autoFocus maxLength={32} value={username} disabled={busy} placeholder="已注册用户的账号 ID" onChange={(event) => setUsername(event.target.value)} />
</label>
<p className={styles.note}>对方登录后会收到通知,接受邀请后加入空间。能否读取加入前的历史,取决于各频道的历史开关。</p>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
+2 -3
View File
@@ -8,7 +8,6 @@ import styles from './MobileDrawer.module.css';
interface Props {
rail: { id?: string; onCreate: () => void; onJoin: () => void };
onSettings: () => void;
/** 频道列表(ThreadSidebar),由 Workspace 组装 */
threads: React.ReactNode;
onFiles: () => void;
@@ -25,13 +24,13 @@ function Row({ icon, label, count, onClick }: { icon: IconName; label: string; c
}
/** 手机侧边栏:空间、频道、本频道的文件与成员;账号与外观在底部 */
export function MobileDrawer({ rail, onSettings, threads, onFiles, onMembers, onInvite, onShare, onHome }: Props) {
export function MobileDrawer({ rail, threads, onFiles, onMembers, onInvite, onShare, onHome }: Props) {
const used = useWorkspace((s) => s.channel?.membersUsed);
const max = useWorkspace((s) => s.channel?.maxMembers);
const spaces = useChannels((s) => s.list.length);
return <>
<SpaceRail {...rail} />
<SpaceHeader onSettings={onSettings} />
<SpaceHeader />
<div className={styles.scroll}>
{threads}
<nav className={styles.section} aria-label="本频道">
@@ -54,17 +54,8 @@
width: 100%;
min-height: var(--bar-h);
padding: 0 var(--space-4);
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.header:hover {
background: var(--accent-soft);
}
.header strong {
+4 -5
View File
@@ -24,13 +24,12 @@ export function SpaceRail({ id, onCreate, onJoin }: RailProps) {
</nav>;
}
/** 频道列表顶部:空间名与加密状态,点击打开空间设置 */
export function SpaceHeader({ onSettings }: { onSettings: () => void }) {
/** 频道列表顶部:空间名与加密状态。空间设置入口在顶栏 */
export function SpaceHeader() {
const channel = useWorkspace((s) => s.channel);
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
return <button type="button" className={styles.header} aria-label={`${name} 空间设置`} onClick={onSettings}>
return <div className={styles.header}>
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
<strong>{name}</strong>
<Icon name="settings" size={16} />
</button>;
</div>;
}
+2 -2
View File
@@ -128,7 +128,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />;
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />{threadSidebar}{desktop ? <Notepad /> : null}</>;
const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const explorer = <>
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
@@ -161,7 +161,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
{/* 弹层打开时抽屉留在下面:同时关抽屉(history.back)会把刚压入的弹层历史一并弹掉 */}
{!desktop && drawerOpen ? <Drawer label="侧边栏" onClose={() => setDrawerOpen(false)}>
<MobileDrawer rail={{ id: channel?.id, onCreate: onCreateChannel, onJoin: () => setScanOpen(true) }}
onSettings={() => setSettingsOpen(true)} threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')}
threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')}
onInvite={owner ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} />
</Drawer> : null}
{!desktop && moreOpen ? <Sheet title="更多" onClose={() => setMoreOpen(false)}>{moreRows.map((row) => <Button key={row.label} variant="ghost" onClick={() => { setMoreOpen(false); row.run(); }}><Icon name={row.icon} size={18} />{row.label}</Button>)}</Sheet> : null}
@@ -150,6 +150,34 @@
grid-column: 1 / -1;
}
.rules {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.rules li {
display: flex;
gap: var(--space-2);
}
.rules li[data-met='true'] {
color: var(--text);
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.fieldError {
color: var(--danger);
font-size: 12px;
+15 -4
View File
@@ -43,6 +43,14 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
}, [account]);
const creating = register && !unlockUsername;
const mismatch = creating && confirm !== '' && password !== confirm;
// 与服务端一致:ID 3–32 位,仅字母、数字、_、-
const rules = [
{ text: 'ID 为 3–32 个字符', met: username.length >= 3 && username.length <= 32 },
{ text: 'ID 只能包含字母、数字、下划线 _ 和连字符 -', met: username !== '' && /^[A-Za-z0-9_-]+$/.test(username) },
{ text: '密码至少 12 个字符', met: password.length >= 12 },
{ text: '两次输入的密码一致', met: confirm !== '' && password === confirm },
];
const unmet = creating && rules.some((rule) => !rule.met);
async function clearSaved() {
edited.current = true;
try { await clearSavedLogin(); setSaved(false); setRemember(false); setUsername(''); setPassword(''); setMessage(account ? '本机保存已清除,当前页面仍可使用;刷新后需要重新登录。' : '已清除本机保存的账号和密码。'); }
@@ -81,14 +89,17 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
<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>账号 ID<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> : null}
{creating ? <ul className={styles.rules} aria-label="注册要求">
{rules.map((rule) => <li key={rule.text} data-met={rule.met}><span aria-hidden="true">{rule.met ? '✓' : '○'}</span>{rule.text}<span className={styles.srOnly}>{rule.met ? ',已满足' : ',未满足'}</span></li>)}
</ul> : null}
{creating ? <p className={styles.note}>账号 ID 是登录用的唯一标识,不是显示名称;显示名称可在登录后的个人资料里设置。密码用于在本机加密你的资源保险库,<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 || mismatch}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady || unmet}>{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={() => {
@@ -0,0 +1,137 @@
.editor {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.main {
display: flex;
flex-wrap: wrap;
gap: var(--space-6);
}
.left {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.stage {
position: relative;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--surface-2);
}
.canvas {
display: block;
cursor: grab;
touch-action: none;
}
.canvas:active {
cursor: grabbing;
}
/* 圆形取景框:圆外压暗,圆内透明;不拦截拖动 */
.mask {
position: absolute;
inset: 0;
background: radial-gradient(circle closest-side, transparent calc(100% - 1px), var(--scrim) 100%);
pointer-events: none;
}
.slider {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
}
.slider span {
flex: 0 0 32px;
}
.slider input[type='range'] {
flex: 1;
min-width: 0;
accent-color: var(--accent);
}
.preview {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-3);
min-width: 200px;
}
.preview h3 {
margin: 0;
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.message,
.member {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.member {
align-items: center;
}
.message p {
margin: 0;
font-size: 14px;
line-height: 1.5;
}
.message small,
.member small {
margin-inline-start: var(--space-2);
color: var(--text-dim);
font-size: 11px;
}
.member div {
font-size: 14px;
}
.avatar,
.avatarSmall {
flex: 0 0 auto;
border-radius: var(--radius-avatar);
background: var(--surface-2);
}
.avatar {
width: 40px;
height: 40px;
}
.avatarSmall {
width: 32px;
height: 32px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.error {
margin: 0;
color: var(--danger);
font-size: 13px;
}
+143
View File
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { Button } from '@/ui/Button';
import { clampCrop, exportAvatar, paintCrop, type Crop } from './avatarCrop';
import styles from './AvatarEditor.module.css';
const VIEW = 260;
interface View { zoom: number; x: number; y: number; quarter: number; fine: number }
const FRESH: View = { zoom: 1, x: 0, y: 0, quarter: 0, fine: 0 };
/** 头像预览:同一份裁剪结果按实际使用的尺寸画出来 */
function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size: number }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
canvas.width = canvas.height = Math.round(size * (window.devicePixelRatio || 1));
paintCrop(ctx, image, crop, VIEW, canvas.width);
}, [image, crop, size]);
return <canvas ref={ref} className={styles.avatar} style={{ width: size, height: size }} aria-hidden="true" />;
}
interface Props {
file: File;
/** 预览里显示的名称 */
name: string;
onCancel: () => void;
onDone: (dataUrl: string) => void;
}
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
const [image, setImage] = useState<ImageBitmap>();
const [failed, setFailed] = useState(false);
const [view, setView] = useState<View>(FRESH);
const [busy, setBusy] = useState(false);
const stage = useRef<HTMLCanvasElement>(null);
const drag = useRef<{ x: number; y: number } | null>(null);
const crop = useMemo<Crop>(() => ({ zoom: view.zoom, x: view.x, y: view.y, angle: view.quarter * 90 + view.fine }), [view]);
useEffect(() => {
let active = true;
let bitmap: ImageBitmap | undefined;
createImageBitmap(file).then((value) => {
if (!active) { value.close(); return; }
bitmap = value; setImage(value); setView(FRESH);
}).catch(() => { if (active) setFailed(true); });
return () => { active = false; bitmap?.close(); };
}, [file]);
const update = useCallback((next: (value: View) => View) => {
if (!image) return;
setView((value) => {
const moved = next(value);
const fixed = clampCrop(image, VIEW, { zoom: moved.zoom, x: moved.x, y: moved.y, angle: moved.quarter * 90 + moved.fine });
return { ...moved, x: fixed.x, y: fixed.y };
});
}, [image]);
useEffect(() => {
const canvas = stage.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx || !image) return;
canvas.width = canvas.height = Math.round(VIEW * (window.devicePixelRatio || 1));
paintCrop(ctx, image, crop, VIEW, canvas.width);
}, [image, crop]);
// 滚轮缩放需要 preventDefault,React 的 onWheel 是被动监听
useEffect(() => {
const canvas = stage.current;
if (!canvas) return;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
update((value) => ({ ...value, zoom: Math.min(4, Math.max(1, value.zoom * Math.exp(-event.deltaY / 400))) }));
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => canvas.removeEventListener('wheel', onWheel);
}, [update, failed]);
function down(event: ReactPointerEvent) {
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { x: event.clientX, y: event.clientY };
}
function move(event: ReactPointerEvent) {
const from = drag.current;
if (!from) return;
drag.current = { x: event.clientX, y: event.clientY };
const dx = event.clientX - from.x;
const dy = event.clientY - from.y;
update((value) => ({ ...value, x: value.x + dx, y: value.y + dy }));
}
async function confirm() {
if (!image || busy) return;
setBusy(true);
try { onDone(await exportAvatar(image, crop, VIEW)); }
catch { setFailed(true); setBusy(false); }
}
if (failed) return <div className={styles.editor}>
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG 或 WebP 图片。</p>
<div className={styles.actions}><Button onClick={onCancel}>返回</Button></div>
</div>;
const ready = Boolean(image);
return <div className={styles.editor}>
<div className={styles.main}>
<div className={styles.left}>
<div className={styles.stage} style={{ width: VIEW, height: VIEW }}>
<canvas ref={stage} className={styles.canvas} style={{ width: VIEW, height: VIEW }} aria-label="拖动图片调整头像位置"
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
<span className={styles.mask} aria-hidden="true" />
</div>
<label className={styles.slider}><span>缩放</span>
<input type="range" min={1} max={4} step={0.01} value={view.zoom} disabled={!ready}
onChange={(event) => update((value) => ({ ...value, zoom: Number(event.target.value) }))} /></label>
<div className={styles.slider}><span>旋转</span>
<Button icon="rotateLeft" label="向左转 90°" disabled={!ready} onClick={() => update((value) => ({ ...value, quarter: value.quarter - 1 }))} />
<Button icon="rotateRight" label="向右转 90°" disabled={!ready} onClick={() => update((value) => ({ ...value, quarter: value.quarter + 1 }))} />
<input type="range" min={-45} max={45} step={1} value={view.fine} disabled={!ready} aria-label="微调角度"
onChange={(event) => update((value) => ({ ...value, fine: Number(event.target.value) }))} />
</div>
</div>
<div className={styles.preview} aria-label="头像预览">
<h3>预览</h3>
<div className={styles.message}>
{image ? <Preview image={image} crop={crop} size={40} /> : <span className={styles.avatar} />}
<div><p><strong>{name}</strong><small>刚刚</small></p><p>大家好~</p></div>
</div>
<div className={styles.member}>
{image ? <Preview image={image} crop={crop} size={32} /> : <span className={styles.avatarSmall} />}
<div><strong>{name}</strong><small>在线</small></div>
</div>
</div>
</div>
<div className={styles.actions}>
<Button variant="ghost" onClick={() => setView(FRESH)} disabled={!ready}>重置</Button>
<Button variant="ghost" onClick={onCancel}>取消</Button>
<Button variant="primary" onClick={() => { void confirm(); }} disabled={!ready || busy}>{busy ? '处理中…' : '使用头像'}</Button>
</div>
</div>;
}
+17 -15
View File
@@ -3,22 +3,18 @@ import { errorMessage } from '@/core-bridge/errors';
import { useAccount } from '@/store/account';
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import { AvatarEditor } from './AvatarEditor';
import styles from './AccountSettings.module.css';
const MAX_AVATAR_BYTES = 250_000;
// 原图只做初步检查;裁剪后会重新编码为 256×256,体积由编辑器控制
const MAX_SOURCE_BYTES = 20_000_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 图片');
async function checkAvatarSource(file: File): Promise<void> {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG 或 WebP 图片');
const bitmap = await createImageBitmap(file);
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096;
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
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);
});
if (!valid) throw new Error('图片宽高不能超过 8192 像素');
}
export function ProfileSettings() {
@@ -29,6 +25,7 @@ export function ProfileSettings() {
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(personal?.displayName ?? '');
const [avatar, setAvatar] = useState(personal?.avatar);
const [editing, setEditing] = useState<File>();
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
const changed = name.trim() !== (personal?.displayName ?? '') || avatar !== personal?.avatar;
@@ -36,7 +33,7 @@ export function ProfileSettings() {
async function choose(file?: File) {
if (!file) return;
try { setAvatar(await readAvatar(file)); }
try { await checkAvatarSource(file); setEditing(file); }
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
}
async function submit(event: FormEvent) {
@@ -46,6 +43,11 @@ export function ProfileSettings() {
catch (cause) { useToast.getState().push({ kind: 'error', text: `保存失败:${errorMessage(cause)}` }); }
}
if (editing) return <div className={styles.page}>
<h2>编辑头像</h2>
<AvatarEditor file={editing} name={shown} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
</div>;
return <form className={styles.page} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
<h2>个人资料</h2>
<div className={styles.avatarRow}>
@@ -57,15 +59,15 @@ export function ProfileSettings() {
<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>
<span className={styles.hint}>PNG、JPEG 或 WebP,可在浏览器里裁剪</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}>用户名
<div className={styles.field}>账号 ID
<span className={styles.readonly}>{username}</span>
<span className={styles.hint}>用户名是固定的账号身份,不能修改。个人名称与头像由服务端保存,在你的所有设备上同步。</span>
<span className={styles.hint}>账号 ID 是固定的账号身份,不能修改。个人名称与头像由服务端保存,在你的所有设备上同步。</span>
</div>
<div className={styles.footer}><Button type="submit" variant="primary" disabled={!changed || busy}>{busy ? '保存中…' : '保存'}</Button></div>
</form>;
+73
View File
@@ -0,0 +1,73 @@
/** 头像裁剪的几何:图片以视口中心为原点平移、旋转、缩放,圆形取景框始终被图片完全盖住 */
export interface Crop {
/** 相对「刚好盖住取景框」的放大倍数,≥ 1 */
zoom: number;
/** 旋转角度(度) */
angle: number;
/** 图片中心相对视口中心的偏移(视口像素) */
x: number;
y: number;
}
export const OUTPUT_SIZE = 256;
export const MAX_OUTPUT_BYTES = 250_000;
type Source = CanvasImageSource & { width: number; height: number };
function scaleOf(source: Source, size: number, zoom: number): number {
return (size / Math.min(source.width, source.height)) * zoom;
}
/** 把偏移限制在「取景圆仍在图片内」的范围 */
export function clampCrop(source: Source, size: number, crop: Crop): Crop {
const s = scaleOf(source, size, crop.zoom);
const rad = (crop.angle * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// 取景圆心在图片坐标系中的位置(图片像素)
const px = (-crop.x * cos - crop.y * sin) / s;
const py = (crop.x * sin - crop.y * cos) / s;
const reach = size / 2 / s;
const limitX = Math.max(0, source.width / 2 - reach);
const limitY = Math.max(0, source.height / 2 - reach);
const cx = Math.min(limitX, Math.max(-limitX, px));
const cy = Math.min(limitY, Math.max(-limitY, py));
return { ...crop, x: -(cx * cos - cy * sin) * s, y: -(cx * sin + cy * cos) * s };
}
export function paintCrop(ctx: CanvasRenderingContext2D, image: Source, crop: Crop, from: number, to: number): void {
const ratio = to / from;
ctx.save();
ctx.clearRect(0, 0, to, to);
ctx.imageSmoothingQuality = 'high';
ctx.translate(to / 2 + crop.x * ratio, to / 2 + crop.y * ratio);
ctx.rotate((crop.angle * Math.PI) / 180);
const s = scaleOf(image, from, crop.zoom) * ratio;
ctx.scale(s, s);
ctx.drawImage(image, -image.width / 2, -image.height / 2);
ctx.restore();
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('头像读取失败'));
reader.readAsDataURL(blob);
});
}
/** 导出 256×256 的 WebP(浏览器不支持时退回 PNG),质量逐步降低直到不超过上限 */
export async function exportAvatar(image: Source, crop: Crop, from: number): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = OUTPUT_SIZE;
canvas.height = OUTPUT_SIZE;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('当前浏览器不支持图片处理');
paintCrop(ctx, image, crop, from, OUTPUT_SIZE);
for (const quality of [0.92, 0.8, 0.65, 0.5]) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
if (blob && blob.size <= MAX_OUTPUT_BYTES) return blobToDataUrl(blob);
}
throw new Error('头像压缩后仍然过大,请换一张图片');
}
+1 -1
View File
@@ -20,7 +20,7 @@ export interface WorkspaceData {
locations: Record<string, { threadId: string; folderId?: string; authorAccount?: string }>;
byId: Record<string, Item>;
authors: Record<string, SenderInfo>;
/** 空间成员的资料(昵称、头像),按用户名索引 */
/** 空间成员的资料(昵称、头像),按账号 ID 索引 */
people: Record<string, SenderInfo>;
/** 新的在前 */
order: string[];
+7 -3
View File
@@ -22,6 +22,8 @@ const PATHS = {
tablet: 'M5 4h14v16H5z M11 17h2',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
menu: 'M4 7h16 M4 12h16 M4 17h16',
rotateLeft: 'M4 12a8 8 0 103-6.2 M4 4v5h5',
rotateRight: 'M20 12a8 8 0 11-3-6.2 M20 4v5h-5',
users: 'M9 11a3 3 0 100-6 3 3 0 000 6z M3 19a6 6 0 0112 0 M16 6a3 3 0 010 6 M18 19a5 5 0 00-3-4.6',
undo: 'M9 14L4 9l5-5 M4 9h10a6 6 0 010 12h-3',
warn: 'M12 4l9 16H3z M12 10v4 M12 17h.01',
@@ -38,10 +40,12 @@ const PATHS = {
chevronLeft: 'M15 6l-6 6 6 6',
panelRight: 'M4 5h16v14H4z M15 5v14',
bell: 'M6 16V11a6 6 0 0112 0v5l2 2H4z M10 20h4',
settings: 'M12 8a4 4 0 100 8 4 4 0 000-8z M19.4 15a1.7 1.7 0 00.34 1.87l.06.06-1.7 2.94-.08-.02a1.7 1.7 0 00-1.72.58l-.05.07h-3.4l-.04-.08a1.7 1.7 0 00-1.7-.6l-.09.03-1.7-2.94.06-.07A1.7 1.7 0 009.7 15l-.08-.05v-3.4l.08-.04a1.7 1.7 0 00.6-1.7l-.03-.09 2.94-1.7.07.06a1.7 1.7 0 001.87.34l.06-.04h3.4l.04.08a1.7 1.7 0 001.7.6l.09-.03 1.7 2.94-.06.07A1.7 1.7 0 0021.8 14l.08.05v3.4z',
settings: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z',
} as const;
/** Material Icons 的实心图标,按填充渲染;其余是 1.5 描边线条图标 */
export type IconName = keyof typeof PATHS;
const FILLED: ReadonlySet<IconName> = new Set<IconName>(['settings']);
interface Props {
name: IconName;
@@ -54,8 +58,8 @@ export function Icon({ name, size = 20 }: Props) {
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
fill={FILLED.has(name) ? 'currentColor' : 'none'}
stroke={FILLED.has(name) ? 'none' : 'currentColor'}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"