feat(chat): 会话列表多选改为「多选」模式开关

默认列表不再常驻每行勾选框(观感更干净),表头改为「共 N 个 + 多选」按钮。
点「多选」进入选择模式:显示每行勾选框、全选、批量删除,「完成」退出并清空
选择;批量删除全部成功后自动退出(有失败则留在选择模式便于重试)。单条的
重命名/置顶/删除仍走每行的 ⋯ 菜单。
This commit is contained in:
Autumn-27 committed 2026-08-25 12:08:18 -04:00
1 parent 12541a6dec
commit a9304d5a4f
1 file changed
+62 -25
+62 -25
View File
@@ -6,6 +6,7 @@ import {
ArrowUpIcon,
Bot,
ChevronDownIcon,
ListChecksIcon,
Loader2Icon,
MoreHorizontalIcon,
PaperclipIcon,
@@ -762,6 +763,7 @@ function ConversationItem({
onCancelRename,
onTogglePinned,
onDelete,
selectionMode,
selectedForDelete,
onSelectedForDeleteChange,
}: {
@@ -777,6 +779,7 @@ function ConversationItem({
onCancelRename: () => void;
onTogglePinned: () => void;
onDelete: () => void;
selectionMode: boolean;
selectedForDelete: boolean;
onSelectedForDeleteChange: (checked: boolean) => void;
}) {
@@ -799,12 +802,14 @@ function ConversationItem({
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
<Checkbox
checked={selectedForDelete}
onCheckedChange={(checked) => onSelectedForDeleteChange(checked === true)}
aria-label={`选择对话「${conv.title || "新对话"}」`}
className="ml-1 shrink-0"
/>
{selectionMode && (
<Checkbox
checked={selectedForDelete}
onCheckedChange={(checked) => onSelectedForDeleteChange(checked === true)}
aria-label={`选择对话「${conv.title || "新对话"}」`}
className="ml-1 shrink-0"
/>
)}
{renaming ? (
<input
ref={renameInputRef}
@@ -906,6 +911,10 @@ export default function ChatPage() {
const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState("");
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
// selectionMode gates the multi-select UI: off by default (clean list, no
// checkboxes); the header "多选" button turns it on, "完成" turns it off and
// clears the selection.
const [selectionMode, setSelectionMode] = React.useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
const [bulkDeleting, setBulkDeleting] = React.useState(false);
// Pending text + uploaded attachments handed off from a draft that created a
@@ -991,6 +1000,11 @@ export default function ChatPage() {
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
}
function exitSelectionMode() {
setSelectionMode(false);
setSelectedConversationIds(new Set());
}
async function del(id: number) {
try {
await api.deleteConversation(id);
@@ -1036,6 +1050,9 @@ export default function ChatPage() {
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
}
setBulkDeleteOpen(false);
// Fully successful → return to the clean list; keep selection mode on if
// some failed so the user can retry the remaining ones.
if (failed.length === 0) setSelectionMode(false);
reloadConvs();
} catch (error) {
toast.error(`批量删除失败:${(error as Error).message}`);
@@ -1084,29 +1101,48 @@ export default function ChatPage() {
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
<PlusIcon /> 新建对话
</Button>
{convs.length > 0 && (
<div className="flex items-center gap-2 px-1">
<Checkbox
checked={conversationHeaderChecked}
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
aria-label="选择全部对话"
/>
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
</span>
{selectedConversationCount > 0 && (
{convs.length > 0 &&
(selectionMode ? (
<div className="flex items-center gap-2 px-1">
<Checkbox
checked={conversationHeaderChecked}
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
aria-label="选择全部对话"
/>
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
</span>
{selectedConversationCount > 0 && (
<Button
size="sm"
variant="destructive"
disabled={bulkDeleting}
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
删除
</Button>
)}
<Button size="sm" variant="ghost" onClick={exitSelectionMode}>
完成
</Button>
</div>
) : (
<div className="flex items-center gap-2 px-1">
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
共 {convs.length} 个
</span>
<Button
size="sm"
variant="destructive"
disabled={bulkDeleting}
onClick={() => setBulkDeleteOpen(true)}
variant="ghost"
className="text-muted-foreground"
onClick={() => setSelectionMode(true)}
>
<Trash2Icon data-icon="inline-start" />
删除
<ListChecksIcon data-icon="inline-start" />
多选
</Button>
)}
</div>
)}
</div>
))}
</div>
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex min-w-0 flex-col gap-0.5 p-2">
@@ -1126,6 +1162,7 @@ export default function ChatPage() {
onCancelRename={() => setRenamingId(null)}
onTogglePinned={() => void togglePinned(c)}
onDelete={() => del(c.id)}
selectionMode={selectionMode}
selectedForDelete={selectedConversationIds.has(c.id)}
onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)}
/>