Compare commits
6
Commits
fb5800f81d
...
55f1b70d89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f1b70d89 | ||
|
|
18c871ec85 | ||
|
|
18185c5bd9 | ||
|
|
b847916a5b | ||
|
|
79bfd560cb | ||
|
|
700be2ebae |
No files matched your search
@@ -51,11 +51,21 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
|
||||
public T Read<T>(Func<IResourceTx, T> action)
|
||||
{
|
||||
using var conn = Open();
|
||||
// 显式事务让一次 Read 内的多条查询看到同一份快照
|
||||
// 读事务延迟到首次访问时才取快照,避免并发读预检抢占 SQLite 写锁。
|
||||
using var tx = conn.BeginTransaction(deferred: true);
|
||||
return action(new SqliteTx(conn));
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
using var conn = Open();
|
||||
// 显式事务让一次 Read 内的多条查询看到同一份快照
|
||||
// 读事务延迟到首次访问时才取快照,避免并发读预检抢占 SQLite 写锁。
|
||||
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)
|
||||
|
||||
@@ -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: '请填写空间名称。',
|
||||
|
||||
@@ -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,56 +60,59 @@ 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={membersCanUpload} disabled={!owner} onChange={(event) => setMembersCanUpload(event.target.checked)} />
|
||||
<span>允许成员上传</span>
|
||||
</label>
|
||||
|
||||
<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>
|
||||
{owner ? (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
|
||||
<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>
|
||||
<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>
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={membersCanUpload} disabled={!owner} onChange={(event) => setMembersCanUpload(event.target.checked)} />
|
||||
<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)} /></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 || !dirty || invalid} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button>
|
||||
</div>
|
||||
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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('头像压缩后仍然过大,请换一张图片');
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user