feat(web): space roles, upload queue with pipelined frames, background resume and faster sending
This commit is contained in:
1 parent
96c4335cb5
commit
a574583943
39 files changed
+1515
-194
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { RoleInfo } from '@/core-bridge/types';
|
||||
import { PERMISSIONS, ROLE_COLORS } from '@/lib/roles';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './RolesEditor.module.css';
|
||||
|
||||
const COLOR_LABEL = { gray: '灰', blue: '蓝', green: '绿', amber: '黄', red: '红', purple: '紫' } as const;
|
||||
|
||||
function RoleForm({ role, members, onDone }: { role?: RoleInfo; members: ChannelMember[]; onDone: (id?: string) => void }) {
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const createRole = useWorkspace((s) => s.createRole);
|
||||
const updateRole = useWorkspace((s) => s.updateRole);
|
||||
const deleteRole = useWorkspace((s) => s.deleteRole);
|
||||
const setRoleMembers = useWorkspace((s) => s.setRoleMembers);
|
||||
const [name, setName] = useState(role?.name ?? '');
|
||||
const [prefix, setPrefix] = useState(role?.prefix ?? '');
|
||||
const [suffix, setSuffix] = useState(role?.suffix ?? '');
|
||||
const [color, setColor] = useState<string>(role?.color ?? 'gray');
|
||||
const [permissions, setPermissions] = useState<ReadonlySet<string>>(new Set(role?.permissions));
|
||||
const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set(role?.members));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const toggle = (set: ReadonlySet<string>, id: string) => { const next = new Set(set); if (next.has(id)) next.delete(id); else next.add(id); return next; };
|
||||
|
||||
async function run(task: () => Promise<string | undefined>) {
|
||||
setBusy(true);
|
||||
try { onDone(await task()); }
|
||||
catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const save = () => run(async () => {
|
||||
const request = { name: name.trim(), prefix: prefix.trim() || null, suffix: suffix.trim() || null, color, permissions: [...permissions] };
|
||||
let id = role?.id;
|
||||
if (id) await updateRole(id, request); else id = await createRole(request);
|
||||
await setRoleMembers(id, [...chosen]);
|
||||
useToast.getState().push({ kind: 'ok', text: '身份组已保存' });
|
||||
return id;
|
||||
});
|
||||
const remove = () => run(async () => { if (role) await deleteRole(role.id); return undefined; });
|
||||
const sample = `${prefix.trim()}${prefix.trim() ? ' ' : ''}你的名字${suffix.trim() ? ' ' : ''}${suffix.trim()}`;
|
||||
|
||||
return <form className={styles.form} onSubmit={(event) => { event.preventDefault(); if (name.trim()) void save(); }}>
|
||||
<label className={styles.field}>名称
|
||||
<input maxLength={48} value={name} placeholder="例如:管理组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<div className={styles.pair}>
|
||||
<label className={styles.field}>前缀(可选)
|
||||
<input maxLength={12} value={prefix} placeholder="例如:【组长】" onChange={(event) => setPrefix(event.target.value)} /></label>
|
||||
<label className={styles.field}>后缀(可选)
|
||||
<input maxLength={12} value={suffix} placeholder="例如:管理员" onChange={(event) => setSuffix(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className={styles.field}>颜色 · 聊天里显示为 <b>{sample}</b>
|
||||
<div className={styles.colors} role="radiogroup" aria-label="标签颜色">
|
||||
{ROLE_COLORS.map((item) => <button key={item} type="button" role="radio" aria-checked={color === item} className={`${styles.swatch} ${styles[item]}`}
|
||||
onClick={() => setColor(item)}>{COLOR_LABEL[item]}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className={styles.group}><legend>权限</legend>
|
||||
{PERMISSIONS.map((item) => <label key={item.id} className={styles.check}>
|
||||
<input type="checkbox" checked={permissions.has(item.id)} onChange={() => setPermissions(toggle(permissions, item.id))} />
|
||||
<span>{item.label}<small>{item.hint}</small></span></label>)}
|
||||
</fieldset>
|
||||
<fieldset className={styles.group}><legend>成员 · {chosen.size}</legend>
|
||||
{members.length ? members.map((member) => <label key={member.username} className={styles.check}>
|
||||
<input type="checkbox" checked={chosen.has(member.username)} onChange={() => setChosen(toggle(chosen, member.username))} />
|
||||
<span>{people[member.username]?.displayName || member.username}<small>{member.username}</small></span></label>)
|
||||
: <p className={styles.empty}>还没有其他成员。成员接受邀请后可以加入身份组。</p>}
|
||||
</fieldset>
|
||||
<div className={styles.actions}>
|
||||
{role ? (confirmDelete
|
||||
? <><Button variant="ghost" disabled={busy} onClick={() => setConfirmDelete(false)}>先不删</Button><Button variant="danger" disabled={busy} onClick={() => { void remove(); }}>确认删除</Button></>
|
||||
: <Button variant="ghost" icon="trash" disabled={busy} onClick={() => setConfirmDelete(true)}>删除身份组</Button>) : null}
|
||||
<Button type="submit" variant="primary" disabled={busy || !name.trim()}>{busy ? '保存中…' : role ? '保存' : '创建身份组'}</Button>
|
||||
</div>
|
||||
</form>;
|
||||
}
|
||||
|
||||
/** 空间设置里的「身份组」页:左边是身份组列表,右边是编辑表单 */
|
||||
export function RolesEditor() {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
useEffect(() => {
|
||||
if (!channelId) return;
|
||||
let active = true;
|
||||
channelAccessApi.members(channelId).then((rows) => { if (active) setMembers(rows.filter((row) => row.role !== 'owner')); }).catch(() => {});
|
||||
return () => { active = false; };
|
||||
}, [channelId]);
|
||||
const role = roles.find((item) => item.id === selected);
|
||||
const first = role ?? (selected === '' ? roles[0] : undefined);
|
||||
const creating = !first;
|
||||
|
||||
return <div className={styles.layout}>
|
||||
<ul className={styles.list} aria-label="身份组">
|
||||
{roles.map((item) => <li key={item.id}><button type="button" aria-current={first?.id === item.id} onClick={() => setSelected(item.id)}>
|
||||
<Icon name="users" size={16} /><span>{item.name}</span><small>{item.members.length}</small></button></li>)}
|
||||
<li><button type="button" className={styles.add} aria-current={creating} onClick={() => setSelected('new')}><Icon name="plus" size={16} /><span>新建身份组</span></button></li>
|
||||
</ul>
|
||||
<RoleForm key={first?.id ?? 'new'} role={first} members={members} onDone={(id) => setSelected(id ?? '')} />
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user