Files
WebDrop/web/src/features/channel/RolesEditor.tsx
T

107 lines
6.6 KiB
TypeScript

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