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
@@ -22,6 +22,11 @@
|
||||
height: min(360px, 70dvh);
|
||||
}
|
||||
|
||||
/* 身份组页内容多:加高,列表在滚动时固定在左边 */
|
||||
.tall {
|
||||
height: min(560px, 80dvh);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,17 +3,19 @@ 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 { RolesEditor } from './RolesEditor';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
import { ttlLabel, withCurrent } from '@/lib/settingOptions';
|
||||
|
||||
type Tab = 'basic' | 'content' | 'join';
|
||||
type Tab = 'basic' | 'content' | 'join' | 'roles';
|
||||
const TABS: { id: Tab; label: string; icon: IconName }[] = [
|
||||
{ id: 'basic', label: '基本', icon: 'edit' },
|
||||
{ id: 'content', label: '内容', icon: 'history' },
|
||||
{ id: 'join', label: '加入', icon: 'users' },
|
||||
{ id: 'roles', label: '身份组', icon: 'users' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -66,9 +68,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
|
||||
return (
|
||||
<Sheet title="空间设置" onClose={onClose} wide>
|
||||
<div className={styles.layout}>
|
||||
<div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}>
|
||||
<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)}>
|
||||
{TABS.filter((item) => owner || item.id !== 'roles').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}>
|
||||
@@ -105,8 +107,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除加入密码</span></label> : null}
|
||||
<p className={styles.note}>空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。</p>
|
||||
</> : null}
|
||||
{tab === 'roles' ? <RolesEditor /> : null}
|
||||
</section>
|
||||
{owner ? (
|
||||
{tab === 'roles' ? null : 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>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useAccount } from '@/store/account';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { formatSize, formatTime } from '@/lib/format';
|
||||
import { TransferProgress } from '@/features/item/TransferProgress';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
||||
import { NameWithBadges } from './RoleBadges';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
||||
@@ -16,6 +18,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
||||
const retry = useWorkspace((s) => s.retrySend);
|
||||
const cancelUpload = useWorkspace((s) => s.cancelUpload);
|
||||
const username = useAccount((s) => s.username);
|
||||
const download = useDownload();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
@@ -49,7 +52,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.body}>
|
||||
{!compact ? <header><strong className={styles.senderName} title={sender?.username}>{name}</strong><time>{formatTime(item.ts)}</time></header> : null}
|
||||
{!compact ? <header><NameWithBadges username={sender?.username}><strong className={styles.senderName} title={sender?.username}>{name}</strong></NameWithBadges><time>{formatTime(item.ts)}</time></header> : null}
|
||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||
: <div className={styles.fileCard}>
|
||||
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
@@ -59,7 +62,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
||||
</div>}
|
||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <TransferProgress state={item.state} {...(self && item.localId ? { onCancel: () => { void cancelUpload(item.localId!); } } : {})} />
|
||||
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,13 @@ import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { permissionsOf } from '@/lib/roles';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { NameWithBadges } from './RoleBadges';
|
||||
import styles from './MembersPane.module.css';
|
||||
|
||||
interface Props { owner: boolean; refreshKey: number; onInvite: () => void }
|
||||
interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void }
|
||||
|
||||
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
|
||||
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
|
||||
@@ -22,10 +25,12 @@ function Section({ title, count, children }: { title: string; count: number; chi
|
||||
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
|
||||
|
||||
const PRESENCE_POLL_MS = 8000;
|
||||
export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const self = useAccount((s) => s.username);
|
||||
const listInvitations = useWorkspace((s) => s.listInvitations);
|
||||
const revoke = useWorkspace((s) => s.revokeInvitation);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
@@ -37,10 +42,10 @@ export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
if (!channelId) return;
|
||||
try {
|
||||
setMembers(await channelAccessApi.members(channelId));
|
||||
setPending(owner ? await listInvitations() : []);
|
||||
setPending(canRemove ? await listInvitations() : []);
|
||||
setError('');
|
||||
} catch { setError('成员清单读取失败'); }
|
||||
}, [channelId, owner, listInvitations]);
|
||||
}, [channelId, canRemove, listInvitations]);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// 在线状态不走订阅流,成员页可见期间定时读取
|
||||
@@ -66,15 +71,17 @@ export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
const name = person?.displayName || username;
|
||||
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
|
||||
<Avatar person={person} name={name} />
|
||||
<span className={styles.who}><strong>{name}{isOwner ? <em>空间主</em> : null}</strong>
|
||||
<span className={styles.who}><strong><NameWithBadges username={username}>{name}</NameWithBadges></strong>
|
||||
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
|
||||
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
|
||||
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
|
||||
</li>;
|
||||
};
|
||||
// 被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人
|
||||
const removable = (item: ChannelMember) => item.role !== 'owner' && (owner || (canRemove && item.username !== self && !permissionsOf(roles, item.username, false).has('members.remove')));
|
||||
const member = (item: ChannelMember) => row(item.username,
|
||||
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
|
||||
false, owner && item.role !== 'owner', item.role === 'owner', item.online);
|
||||
false, removable(item), item.role === 'owner', item.online);
|
||||
|
||||
return <div className={styles.pane}>
|
||||
<div className={styles.head}>
|
||||
@@ -86,7 +93,7 @@ export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
<Section title="离线" count={offline.length}>{offline.map(member)}</Section>
|
||||
<Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, owner))}</Section>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{!owner ? <p className={styles.note}>只有空间主可以邀请或移除成员。</p> : null}
|
||||
{!owner ? <p className={styles.note}>{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}</p> : null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.badge {
|
||||
--c: var(--text-dim);
|
||||
|
||||
display: inline-block;
|
||||
margin-inline: var(--space-1);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--c);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.owner {
|
||||
--c: var(--text);
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--c: var(--busy);
|
||||
}
|
||||
|
||||
.green {
|
||||
--c: var(--ok);
|
||||
}
|
||||
|
||||
.amber {
|
||||
--c: var(--warn);
|
||||
}
|
||||
|
||||
.red {
|
||||
--c: var(--danger);
|
||||
}
|
||||
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { badgesOf, type Badge } from '@/lib/roles';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import styles from './RoleBadges.module.css';
|
||||
|
||||
function List({ items }: { items: Badge[] }) {
|
||||
return <>{items.map((badge) => <span key={badge.key} className={`${styles.badge} ${styles[badge.color]}`}>{badge.text}</span>)}</>;
|
||||
}
|
||||
|
||||
/** 名字前后的身份标签:空间主固定一个,其余是身份组里自定义的前缀、后缀 */
|
||||
export function useBadges(username: string | undefined): { prefix: Badge[]; suffix: Badge[] } {
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const creator = useWorkspace((s) => s.channel?.creatorAccount);
|
||||
return useMemo(() => badgesOf(roles, username, creator), [roles, username, creator]);
|
||||
}
|
||||
|
||||
export function NameWithBadges({ username, children }: { username: string | undefined; children: React.ReactNode }) {
|
||||
const { prefix, suffix } = useBadges(username);
|
||||
return <><List items={prefix} />{children}<List items={suffix} /></>;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 148px minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list button:hover,
|
||||
.list button[aria-current='true'] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.list span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list small {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
--c: var(--text-dim);
|
||||
|
||||
min-width: 36px;
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--c);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatch[aria-checked='true'] {
|
||||
border-color: var(--c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--c: var(--busy);
|
||||
}
|
||||
|
||||
.green {
|
||||
--c: var(--ok);
|
||||
}
|
||||
|
||||
.amber {
|
||||
--c: var(--warn);
|
||||
}
|
||||
|
||||
.red {
|
||||
--c: var(--danger);
|
||||
}
|
||||
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.group legend {
|
||||
padding: 0 var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
min-height: 32px;
|
||||
padding: var(--space-1) 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.check small {
|
||||
display: block;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@media (width <= 720px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.list {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.list button {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -9,7 +9,10 @@ interface Props {
|
||||
threads: ThreadInfo[];
|
||||
groups: ChannelGroupInfo[];
|
||||
selectedThreadId?: string;
|
||||
/** 能重命名频道、管理分组(空间主,或被授予「管理频道和分组」) */
|
||||
enabled: boolean;
|
||||
/** 新建频道涉及密钥,只有空间主 */
|
||||
canCreate: boolean;
|
||||
owner: boolean;
|
||||
onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise<void>;
|
||||
onSelectThread: (id: string) => void;
|
||||
@@ -72,7 +75,7 @@ export function ThreadSidebar(props: Props) {
|
||||
<button className={styles.groupToggle} type="button" aria-label="未分组频道" aria-expanded={!collapsed.has(UNGROUPED)} onClick={() => toggle(UNGROUPED)}>
|
||||
<Icon name={collapsed.has(UNGROUPED) ? 'chevronRight' : 'chevronDown'} size={14} /><span>未分组</span>
|
||||
</button>
|
||||
{props.enabled ? <span className={styles.categoryActions}>
|
||||
{props.canCreate ? <span className={styles.categoryActions}>
|
||||
<Button variant="ghost" icon="plus" label="在未分组新建频道" disabled={busy} onClick={() => begin({ kind: 'channel' })} />
|
||||
</span> : null}
|
||||
</div>
|
||||
@@ -85,7 +88,7 @@ export function ThreadSidebar(props: Props) {
|
||||
<div className={styles.groupHeading}><button className={styles.groupToggle} type="button" aria-label={`分组 ${group.name}`} aria-expanded={!collapsed.has(group.id)} onClick={() => toggle(group.id)}>
|
||||
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span>
|
||||
</button>{props.enabled ? <span className={styles.categoryActions}>
|
||||
<Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
|
||||
{props.canCreate ? <Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} /> : null}
|
||||
<Button className={styles.groupMore} variant="ghost" icon="more" label={`分组设置 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
|
||||
</span> : null}</div>
|
||||
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} nested onSelect={select}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Icon, type IconName } from '@/ui/Icon';
|
||||
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
||||
import { MobileDrawer } from './MobileDrawer';
|
||||
import { MobileTopBar } from './MobileTopBar';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { permissionsOf } from '@/lib/roles';
|
||||
import { ThreadSidebar } from './ThreadSidebar';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileExplorer } from './FileExplorer';
|
||||
@@ -82,6 +84,9 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
|
||||
const enabled = Boolean(activeThreadId && channel?.canUpload);
|
||||
const owner = channel?.role === 'owner';
|
||||
const roles = useWorkspace((s) => s.roles);
|
||||
const self = useAccount((s) => s.username);
|
||||
const perms = permissionsOf(roles, self ?? undefined, owner);
|
||||
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
|
||||
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
|
||||
@@ -126,7 +131,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const channelId = channel?.id;
|
||||
useEffect(() => { setDrawerOpen(false); }, [channelId]);
|
||||
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
|
||||
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
|
||||
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure) || perms.has('structure.manage')} canCreate={owner} owner={owner} onHistory={setChannelHistory}
|
||||
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />;
|
||||
const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>;
|
||||
@@ -143,7 +148,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
|
||||
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
</>;
|
||||
const members = <MembersPane owner={owner} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
|
||||
const members = <MembersPane owner={owner} canRemove={perms.has('members.remove')} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
|
||||
function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); }
|
||||
const moreRows: { icon: IconName; label: string; run: () => void }[] = [
|
||||
{ icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) },
|
||||
|
||||
Reference in new issue
Block a user