feat(web): space roles, upload queue with pipelined frames, background resume and faster sending

This commit is contained in:
justme committed 2026-10-16 19:42:33 +08:00
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>
+6 -3
View File
@@ -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>
+14 -7
View File
@@ -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);
}
+20
View File
@@ -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;
}
}
+106
View File
@@ -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>;
}
+5 -2
View File
@@ -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}
+7 -2
View File
@@ -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) },