From 6d6490055e5157d17874f4ef7bef66b41c7bfce7 Mon Sep 17 00:00:00 2001 From: JustMe Date: Fri, 16 Oct 2026 18:30:58 +0800 Subject: [PATCH] feat(web): tidy the channel sidebar and let owners delete groups --- web/src/core-bridge/api.ts | 1 + web/src/core-bridge/real.ts | 1 + web/src/core-bridge/worker.ts | 3 +- web/src/core-bridge/worker/channelThreads.ts | 3 ++ .../core-bridge/worker/workspaceStructure.ts | 2 +- web/src/features/channel/Notepad.module.css | 2 +- .../features/channel/ThreadSidebar.module.css | 32 +++++++++++++++---- web/src/features/channel/ThreadSidebar.tsx | 22 +++++++++++-- web/src/features/channel/Workspace.tsx | 3 +- web/src/store/workspace.ts | 2 ++ web/src/ui/Button.module.css | 6 ++++ web/src/ui/Button.tsx | 2 +- 12 files changed, 66 insertions(+), 13 deletions(-) diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts index 56097ed..43292b1 100644 --- a/web/src/core-bridge/api.ts +++ b/web/src/core-bridge/api.ts @@ -27,6 +27,7 @@ export interface WorkspaceApi { createThread(name: string, groupId?: string): Promise; createGroup(name: string): Promise; renameGroup(id: string, name: string): Promise; + deleteGroup(id: string): Promise; groupThread(id: string, groupId?: string): Promise; renameThread(threadId: string, name: string): Promise; setChannelHistory(threadId: string, chatHistory: boolean, filesHistory: boolean): Promise; diff --git a/web/src/core-bridge/real.ts b/web/src/core-bridge/real.ts index f129b2e..92ea154 100644 --- a/web/src/core-bridge/real.ts +++ b/web/src/core-bridge/real.ts @@ -39,6 +39,7 @@ export const realApi: WorkspaceApi = { sendFiles: (files, options, threadId, folderId, localIds) => callWorker('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }), createThread: (name, groupId) => callWorker('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }), createGroup: (name) => callWorker('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }), + deleteGroup: (groupId) => callWorker('deleteGroup', { id: currentChannelId(), groupId, apiBase: API_BASE }), renameGroup: (groupId, name) => callWorker('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }), groupThread: (threadId, groupId) => callWorker('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }), renameThread: (threadId, name) => callWorker('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }), diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts index 8879757..e40ea88 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -12,7 +12,7 @@ import { setEventSink } from './worker/port'; import { serial } from './worker/serial'; import { errorMessage } from './errors'; import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; -import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads'; +import { createFolder, createThread, createGroup, renameGroup, deleteGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads'; import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; interface WorkerRequest { requestId: number; operation: string; payload: unknown } @@ -62,6 +62,7 @@ async function dispatch(operation: string, input: unknown): Promise { case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters[1]); case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined); case 'createGroup': return createGroup(id, String(payload.name ?? '')); + case 'deleteGroup': return deleteGroup(id, String(payload.groupId ?? '')); case 'renameGroup': return renameGroup(id, String(payload.groupId ?? ''), String(payload.name ?? '')); case 'groupThread': return groupThread(id, String(payload.threadId ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined); case 'renameThread': return renameThread(id, String(payload.threadId ?? ''), String(payload.name ?? '')); diff --git a/web/src/core-bridge/worker/channelThreads.ts b/web/src/core-bridge/worker/channelThreads.ts index 7bdf484..1d4f4d7 100644 --- a/web/src/core-bridge/worker/channelThreads.ts +++ b/web/src/core-bridge/worker/channelThreads.ts @@ -24,6 +24,9 @@ export async function createGroup(resourceId: string, name: string): Promise { await changeWorkspace(resourceId, 'groups/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); } +export async function deleteGroup(resourceId: string, id: string): Promise { + await changeWorkspace(resourceId, 'groups/' + id, 'DELETE'); +} export async function groupThread(resourceId: string, id: string, groupId?: string): Promise { await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest); } diff --git a/web/src/core-bridge/worker/workspaceStructure.ts b/web/src/core-bridge/worker/workspaceStructure.ts index a546c80..94992ab 100644 --- a/web/src/core-bridge/worker/workspaceStructure.ts +++ b/web/src/core-bridge/worker/workspaceStructure.ts @@ -37,7 +37,7 @@ export async function loadWorkspace(id: string): Promise { const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise); return publish(id, body); } -export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT', value: unknown): Promise { +export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise { const { session } = await getSession(id); const body = await apiFetch(`/spaces/${id}/${path}`, { method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value), diff --git a/web/src/features/channel/Notepad.module.css b/web/src/features/channel/Notepad.module.css index e6a0bdc..46ff57d 100644 --- a/web/src/features/channel/Notepad.module.css +++ b/web/src/features/channel/Notepad.module.css @@ -1,6 +1,6 @@ .pad { margin-top: var(--space-2); - padding: var(--space-1) var(--space-2); + padding: var(--space-1) var(--space-3); border-top: 1px solid var(--line); } diff --git a/web/src/features/channel/ThreadSidebar.module.css b/web/src/features/channel/ThreadSidebar.module.css index ae8e533..4feac9a 100644 --- a/web/src/features/channel/ThreadSidebar.module.css +++ b/web/src/features/channel/ThreadSidebar.module.css @@ -105,6 +105,15 @@ border: 0; } +.dangerZone { + display: flex; + flex-direction: column; + gap: var(--space-2); + align-items: flex-start; + padding-top: var(--space-2); + border-top: 1px solid var(--line); +} + .modalActions { justify-content: flex-end; margin-top: var(--space-2); @@ -147,11 +156,19 @@ .heading { min-height: 32px; - padding-inline: var(--space-1); + padding-inline: var(--space-1) 0; color: var(--text-dim); font-size: 12px; } +.heading button { + width: 26px; + min-width: 26px; + height: 26px; + min-height: 26px; + padding: 0; +} + .folderHeading { padding-top: var(--space-2); border-top: 1px solid var(--line); @@ -167,9 +184,8 @@ list-style: none; } -/* 频道行缩进到分组标题文字之下 */ .threads { - padding-inline-start: var(--space-4); + padding-inline-start: 0; } .nestedThreads { @@ -219,11 +235,13 @@ gap: var(--space-2); min-width: 0; min-height: 32px; - padding: 0 var(--space-2); + padding: 0 var(--space-2) 0 var(--space-1); border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-dim); + font: inherit; + font-size: 14px; text-align: left; cursor: pointer; } @@ -259,12 +277,14 @@ .thread.selected { background: var(--accent-soft); color: var(--text); - font-weight: 600; + font-weight: 500; } .thread > span:first-child { + width: 14px; color: var(--text-dim); - font-size: 16px; + font-size: 14px; + text-align: center; } .empty, diff --git a/web/src/features/channel/ThreadSidebar.tsx b/web/src/features/channel/ThreadSidebar.tsx index b8331dc..1b3186e 100644 --- a/web/src/features/channel/ThreadSidebar.tsx +++ b/web/src/features/channel/ThreadSidebar.tsx @@ -17,6 +17,7 @@ interface Props { onRenameThread: (id: string, name: string) => Promise; onCreateGroup: (name: string) => Promise; onRenameGroup: (id: string, name: string) => Promise; + onDeleteGroup: (id: string) => Promise; onGroupThread: (id: string, groupId?: string) => Promise; } type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string }; @@ -29,16 +30,24 @@ export function ThreadSidebar(props: Props) { const [chatHistory, setChatHistory] = useState(true); const [filesHistory, setFilesHistory] = useState(true); const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); const [error, setError] = useState(''); const [collapsed, setCollapsed] = useState>(new Set()); const knownGroups = new Set(props.groups.map((group) => group.id)); const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId)); function begin(target: Editor, initial = '') { - setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); + setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); setConfirmDelete(false); const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true); } function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function select(id: string) { props.onSelectThread(id); setEdit(undefined); } + async function removeGroup() { + if (!edit?.id || busy || !props.enabled) return; + setBusy(true); setError(''); + try { await props.onDeleteGroup(edit.id); setEdit(undefined); } + catch (cause) { setError(cause instanceof Error ? cause.message : '删除失败'); } + finally { setBusy(false); } + } async function submit(event: FormEvent) { event.preventDefault(); if (!edit || !name.trim() || busy || !props.enabled) return; setBusy(true); setError(''); @@ -57,7 +66,7 @@ export function ThreadSidebar(props: Props) { finally { setBusy(false); } } return
-
频道
+
频道
{(ungrouped.length > 0 || props.groups.length === 0) ?
+ +
+ : } + : null} {error ?

{error}

: null}
diff --git a/web/src/features/channel/Workspace.tsx b/web/src/features/channel/Workspace.tsx index 2e67d95..a374028 100644 --- a/web/src/features/channel/Workspace.tsx +++ b/web/src/features/channel/Workspace.tsx @@ -43,6 +43,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) const setChannelHistory = useWorkspace(s => s.setChannelHistory); const createGroup = useWorkspace((s) => s.createGroup); const renameGroup = useWorkspace((s) => s.renameGroup); + const deleteGroup = useWorkspace((s) => s.deleteGroup); const groupThread = useWorkspace((s) => s.groupThread); const folders = useWorkspace((s) => s.folders); const sendText = useWorkspace((s) => s.sendText); @@ -127,7 +128,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) const rail = setScanOpen(true)} />; const threadSidebar = { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread} - onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />; + onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />; const navigation = <>{threadSidebar}{desktop ? : null}; const chat = sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; const explorer = <> diff --git a/web/src/store/workspace.ts b/web/src/store/workspace.ts index 3304070..0af4d87 100644 --- a/web/src/store/workspace.ts +++ b/web/src/store/workspace.ts @@ -66,6 +66,7 @@ interface Actions { createThread: (name: string, groupId?: string) => Promise; createGroup: typeof api.createGroup; renameGroup: typeof api.renameGroup; + deleteGroup: typeof api.deleteGroup; groupThread: typeof api.groupThread; renameThread: (threadId: string, name: string) => Promise; setChannelHistory: typeof api.setChannelHistory; @@ -112,6 +113,7 @@ export const useWorkspace = create()(() => ({ createThread: (name, groupId) => api.createThread(name, groupId), createGroup: (name) => api.createGroup(name), renameGroup: (id, name) => api.renameGroup(id, name), + deleteGroup: (id) => api.deleteGroup(id), groupThread: (id, groupId) => api.groupThread(id, groupId), renameThread: (threadId, name) => api.renameThread(threadId, name), setChannelHistory: (threadId, chatHistory, filesHistory) => api.setChannelHistory(threadId, chatHistory, filesHistory), diff --git a/web/src/ui/Button.module.css b/web/src/ui/Button.module.css index eb3e1b9..cd2a586 100644 --- a/web/src/ui/Button.module.css +++ b/web/src/ui/Button.module.css @@ -34,6 +34,12 @@ font-weight: 600; } +.danger { + background: var(--danger); + color: var(--on-accent); + font-weight: 600; +} + .quiet { border-color: var(--line-strong); background: var(--surface); diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx index 07da6a5..64087da 100644 --- a/web/src/ui/Button.tsx +++ b/web/src/ui/Button.tsx @@ -3,7 +3,7 @@ import { Icon, type IconName } from './Icon'; import styles from './Button.module.css'; interface Props extends ButtonHTMLAttributes { - variant?: 'primary' | 'quiet' | 'ghost'; + variant?: 'primary' | 'quiet' | 'ghost' | 'danger'; icon?: IconName; /** 仅图标时必填,作为无障碍名称 */ label?: string;