1、将排序做到border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted里,无需单独做个button 2、指定重命名使用ICO,无需使用菜单

This commit is contained in:
pphua committed 2026-08-25 14:56:15 +08:00
1 parent 48e85b33d6
commit 86eb67ea0a
15 files changed
+957 -20

No files matched your search

+135 -1
View File
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
@@ -761,6 +762,8 @@ function ConversationItem({
onCancelRename,
onTogglePinned,
onDelete,
selectedForDelete,
onSelectedForDeleteChange,
}: {
conv: Conversation;
agent?: Agent;
@@ -774,6 +777,8 @@ function ConversationItem({
onCancelRename: () => void;
onTogglePinned: () => void;
onDelete: () => void;
selectedForDelete: boolean;
onSelectedForDeleteChange: (checked: boolean) => void;
}) {
const [deleteOpen, setDeleteOpen] = React.useState(false);
const renameInputRef = React.useRef<HTMLInputElement>(null);
@@ -794,6 +799,12 @@ 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"
/>
{renaming ? (
<input
ref={renameInputRef}
@@ -894,6 +905,9 @@ export default function ChatPage() {
const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState("");
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
const [bulkDeleting, setBulkDeleting] = React.useState(false);
// Pending text + uploaded attachments handed off from a draft that created a
// conversation via the paperclip, keyed by the new conversation id (consumed once
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
@@ -923,6 +937,15 @@ export default function ChatPage() {
reloadConvs();
}, [reloadConvs]);
React.useEffect(() => {
setSelectedConversationIds((current) => {
if (current.size === 0) return current;
const live = new Set(convs.map((conversation) => conversation.id));
const next = new Set([...current].filter((id) => live.has(id)));
return next.size === current.size ? current : next;
});
}, [convs]);
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
// returns to the same thread instead of the empty draft view. Runs after
// hydration (not a lazy useState init) to avoid a server/client mismatch.
@@ -948,16 +971,80 @@ export default function ChatPage() {
// are task-specific and stay hidden from the chat page.
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
const selectedConversationCount = selectedConversationIds.size;
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected;
let conversationHeaderChecked: boolean | "indeterminate" = false;
if (allConversationsSelected) conversationHeaderChecked = true;
else if (someConversationsSelected) conversationHeaderChecked = "indeterminate";
function toggleConversationSelected(id: number, checked: boolean) {
setSelectedConversationIds((current) => {
const next = new Set(current);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
function toggleAllConversations(checked: boolean) {
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
}
async function del(id: number) {
try {
await api.deleteConversation(id);
if (selectedId === id) setSelectedId(null);
setSelectedConversationIds((current) => {
if (!current.has(id)) return current;
const next = new Set(current);
next.delete(id);
return next;
});
reloadConvs();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function deleteSelectedConversations() {
const ids = [...selectedConversationIds];
if (ids.length === 0 || bulkDeleting) return;
setBulkDeleting(true);
const deleted = new Set<number>();
const failed: { id: number; error: string }[] = [];
try {
for (let offset = 0; offset < ids.length; offset += 100) {
const result = await api.deleteConversations(ids.slice(offset, offset + 100));
for (const item of result.items) {
if (item.ok) deleted.add(item.id);
else failed.push({ id: item.id, error: item.error ?? "对话不存在" });
}
}
if (deleted.has(selectedId ?? -1)) setSelectedId(null);
setSelectedConversationIds((current) => {
const next = new Set(current);
for (const id of deleted) next.delete(id);
return next;
});
if (deleted.size > 0) toast.success(`已删除 ${deleted.size} 个对话`);
if (failed.length > 0) {
const details = failed
.slice(0, 3)
.map((item) => `#${item.id}(${item.error})`)
.join(";");
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
}
setBulkDeleteOpen(false);
reloadConvs();
} catch (error) {
toast.error(`批量删除失败:${(error as Error).message}`);
reloadConvs();
} finally {
setBulkDeleting(false);
}
}
async function togglePinned(conversation: Conversation) {
const pinned = conversationIsPinned(conversation);
try {
@@ -993,10 +1080,33 @@ export default function ChatPage() {
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-[minmax(10rem,15rem)_minmax(0,1fr)] gap-3 md:grid-cols-[18rem_minmax(0,1fr)] md:grid-rows-[minmax(0,1fr)] md:gap-4">
{/* left: conversation list */}
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
<div className="border-b p-2">
<div className="flex flex-col gap-2 border-b p-2">
<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 && (
<Button
size="sm"
variant="destructive"
disabled={bulkDeleting}
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
删除
</Button>
)}
</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">
@@ -1016,6 +1126,8 @@ export default function ChatPage() {
onCancelRename={() => setRenamingId(null)}
onTogglePinned={() => void togglePinned(c)}
onDelete={() => del(c.id)}
selectedForDelete={selectedConversationIds.has(c.id)}
onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)}
/>
))}
</div>
@@ -1056,6 +1168,28 @@ export default function ChatPage() {
)}
</div>
</div>
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除选中的 {selectedConversationCount} 个对话?</AlertDialogTitle>
<AlertDialogDescription>对话消息和执行记录将一并删除,此操作不可撤销。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkDeleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={bulkDeleting || selectedConversationCount === 0}
onClick={(event) => {
event.preventDefault();
void deleteSelectedConversations();
}}
>
{bulkDeleting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{bulkDeleting ? "删除中" : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+308 -8
View File
@@ -17,6 +17,9 @@ import {
useSensors,
} from "@dnd-kit/core";
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
ChevronRightIcon,
EyeIcon,
FolderInputIcon,
@@ -24,6 +27,9 @@ import {
Loader2Icon,
PaperclipIcon,
PauseIcon,
PencilIcon,
PinIcon,
PinOffIcon,
PlayIcon,
PlusIcon,
SaveIcon,
@@ -235,12 +241,59 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
return null;
}
type TaskSortField = "id" | "created" | "duration" | "status";
type SortDirection = "asc" | "desc";
const TASK_STATUS_RANK = new Map(STATUS_OPTIONS.map((option, index) => [option.value, index]));
function taskIsPinned(task: Task): boolean {
return task.pinned ?? Boolean(task.pinned_at);
}
function compareTaskIDs(left: string, right: string): number {
return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
}
function taskCreatedUnix(task: Task): number {
if (task.created_unix) return task.created_unix;
const parsed = Date.parse(task.created_at);
return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
}
function compareTasks(left: Task, right: Task, field: TaskSortField, direction: SortDirection, nowSec: number): number {
const leftPinned = taskIsPinned(left);
const rightPinned = taskIsPinned(right);
if (leftPinned !== rightPinned) return leftPinned ? -1 : 1;
if (leftPinned && left.pinned_at !== right.pinned_at) {
return (right.pinned_at ?? "").localeCompare(left.pinned_at ?? "");
}
let compared = 0;
switch (field) {
case "created":
compared = taskCreatedUnix(left) - taskCreatedUnix(right);
break;
case "duration":
compared = taskDuration(left, nowSec) - taskDuration(right, nowSec);
break;
case "status":
compared = (TASK_STATUS_RANK.get(left.status) ?? 0) - (TASK_STATUS_RANK.get(right.status) ?? 0);
break;
default:
compared = compareTaskIDs(left.id, right.id);
}
if (compared !== 0) return direction === "asc" ? compared : -compared;
return -compareTaskIDs(left.id, right.id);
}
export default function TasksPage() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
const [categoryFilter, setCategoryFilter] = React.useState("all");
const [sortField, setSortField] = React.useState<TaskSortField>("id");
const [sortDirection, setSortDirection] = React.useState<SortDirection>("desc");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
@@ -269,15 +322,32 @@ export default function TasksPage() {
});
}, [tasks, query, statusFilter, categoryFilter]);
// reset to page 1 whenever filters change
const sortNowSec = sortField === "duration" ? nowSec : 0;
const ordered = React.useMemo(() => {
return [...filtered].sort((left, right) => compareTasks(left, right, sortField, sortDirection, sortNowSec));
}, [filtered, sortDirection, sortField, sortNowSec]);
const sortTasksBy = React.useCallback(
(field: TaskSortField) => {
if (sortField === field) {
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortField(field);
setSortDirection("desc");
},
[sortField],
);
// reset to page 1 whenever filters or ordering change
// biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination.
React.useEffect(() => {
setPage(1);
}, [query, statusFilter, categoryFilter]);
}, [query, statusFilter, categoryFilter, sortField, sortDirection]);
const paginated = React.useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
() => ordered.slice((page - 1) * pageSize, page * pageSize),
[ordered, page, pageSize],
);
// 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。
@@ -439,6 +509,37 @@ export default function TasksPage() {
[load],
);
const renameTask = React.useCallback(
async (task: Task, name: string) => {
try {
await api.renameTask(task.id, name);
toast.success(`任务 #${task.id} 已重命名`);
lastRef.current = "";
load();
} catch (error) {
toast.error(`重命名失败:${(error as Error).message}`);
throw error;
}
},
[load],
);
const toggleTaskPinned = React.useCallback(
async (task: Task) => {
const pinned = taskIsPinned(task);
try {
await api.pinTask(task.id, !pinned);
toast.success(pinned ? `任务 #${task.id} 已取消置顶` : `任务 #${task.id} 已置顶`);
lastRef.current = "";
load();
} catch (error) {
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(error as Error).message}`);
throw error;
}
},
[load],
);
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
const deleteTasks = React.useCallback(
@@ -717,14 +818,41 @@ export default function TasksPage() {
aria-label="选择本页全部任务"
/>
</TableHead>
<TableHead className="font-mono">ID</TableHead>
<SortableTaskHead
field="id"
label="ID"
activeField={sortField}
direction={sortDirection}
className="font-mono"
onSort={sortTasksBy}
/>
<TableHead>名称</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
<SortableTaskHead
field="status"
label="状态"
activeField={sortField}
direction={sortDirection}
onSort={sortTasksBy}
/>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-right">创建时间</TableHead>
<TableHead className="text-right">运行时长</TableHead>
<SortableTaskHead
field="created"
label="创建时间"
activeField={sortField}
direction={sortDirection}
align="right"
onSort={sortTasksBy}
/>
<SortableTaskHead
field="duration"
label="运行时长"
activeField={sortField}
direction={sortDirection}
align="right"
onSort={sortTasksBy}
/>
<TableHead className="text-right">Token</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">
操作
@@ -741,6 +869,8 @@ export default function TasksPage() {
nowSec={task.status === "running" ? nowSec : 0}
onDelete={deleteTask}
onControl={controlTask}
onRename={renameTask}
onTogglePinned={toggleTaskPinned}
selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected}
/>
@@ -763,6 +893,56 @@ export default function TasksPage() {
);
}
function taskSortIcon(active: boolean, direction: SortDirection) {
if (!active) {
return <ArrowUpDownIcon className="size-3.5 opacity-40 transition-opacity group-hover/sort:opacity-100" />;
}
if (direction === "asc") return <ArrowUpIcon className="size-3.5" />;
return <ArrowDownIcon className="size-3.5" />;
}
function SortableTaskHead({
field,
label,
activeField,
direction,
align = "left",
className,
onSort,
}: {
field: TaskSortField;
label: string;
activeField: TaskSortField;
direction: SortDirection;
align?: "left" | "right";
className?: string;
onSort: (field: TaskSortField) => void;
}) {
const active = activeField === field;
let ariaSort: React.AriaAttributes["aria-sort"] = "none";
if (active) ariaSort = direction === "asc" ? "ascending" : "descending";
let actionLabel = `按${label}倒序排序`;
if (active) actionLabel = `${label}当前${direction === "asc" ? "正序" : "倒序"},点击切换排序方向`;
return (
<TableHead className={className} aria-sort={ariaSort}>
<button
type="button"
className={cn(
"group/sort inline-flex h-full w-full items-center gap-1 outline-none focus-visible:underline",
align === "right" && "justify-end",
)}
aria-label={actionLabel}
onClick={() => onSort(field)}
>
<span>{label}</span>
{taskSortIcon(active, direction)}
</button>
</TableHead>
);
}
function ConcurrencySettingsDialog() {
const [open, setOpen] = React.useState(false);
const [enabled, setEnabled] = React.useState(false);
@@ -858,6 +1038,8 @@ const TaskRow = React.memo(function TaskRow({
nowSec,
onDelete,
onControl,
onRename,
onTogglePinned,
selected,
onSelectedChange,
}: {
@@ -865,6 +1047,8 @@ const TaskRow = React.memo(function TaskRow({
nowSec: number;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
onRename: (task: Task, name: string) => Promise<void>;
onTogglePinned: (task: Task) => Promise<void>;
selected: boolean;
onSelectedChange: (id: string, checked: boolean) => void;
}) {
@@ -890,6 +1074,7 @@ const TaskRow = React.memo(function TaskRow({
{task.name?.trim() ? task.name : <span className="text-muted-foreground">未命名</span>}
</Link>
{task.active && <StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />}
{taskIsPinned(task) && <PinIcon className="text-primary size-4 shrink-0" aria-label="已置顶" />}
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs">
@@ -954,6 +1139,7 @@ const TaskRow = React.memo(function TaskRow({
</Link>
</Button>
<TaskControlButton task={task} onControl={onControl} />
<TaskMetadataActions task={task} onRename={onRename} onTogglePinned={onTogglePinned} />
<DeleteTaskDialog task={task} onDelete={onDelete} />
</div>
</TableCell>
@@ -961,6 +1147,120 @@ const TaskRow = React.memo(function TaskRow({
);
});
function taskPinIcon(pinning: boolean, pinned: boolean) {
if (pinning) return <Spinner />;
if (pinned) return <PinOffIcon />;
return <PinIcon />;
}
function TaskMetadataActions({
task,
onRename,
onTogglePinned,
}: {
task: Task;
onRename: (task: Task, name: string) => Promise<void>;
onTogglePinned: (task: Task) => Promise<void>;
}) {
const [renameOpen, setRenameOpen] = React.useState(false);
const [name, setName] = React.useState(task.name ?? "");
const [saving, setSaving] = React.useState(false);
const [pinning, setPinning] = React.useState(false);
const pinned = taskIsPinned(task);
React.useEffect(() => {
if (renameOpen) setName(task.name ?? "");
}, [renameOpen, task.name]);
async function saveName() {
const next = name.trim();
if (!next) return;
if (next === task.name?.trim()) {
setRenameOpen(false);
return;
}
setSaving(true);
try {
await onRename(task, next);
setRenameOpen(false);
} catch {
// The parent already reports the API error; keep the dialog open for retry.
} finally {
setSaving(false);
}
}
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`重命名任务 #${task.id}`}
title="重命名"
onClick={() => setRenameOpen(true)}
>
<PencilIcon />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={pinning}
aria-label={pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`}
title={pinned ? "取消置顶" : "置顶"}
onClick={async () => {
setPinning(true);
try {
await onTogglePinned(task);
} finally {
setPinning(false);
}
}}
>
{taskPinIcon(pinning, pinned)}
</Button>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>重命名任务 #{task.id}</DialogTitle>
<DialogDescription>新名称只用于列表和详情展示,不会改变任务描述、目标或执行状态。</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor={`task-rename-${task.id}`}>任务名称</FieldLabel>
<Input
id={`task-rename-${task.id}`}
value={name}
maxLength={200}
autoFocus
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void saveName();
}
}}
/>
</Field>
</FieldGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={saving}>
取消
</Button>
</DialogClose>
<Button disabled={saving || !name.trim()} onClick={() => void saveName()}>
{saving && <Spinner data-icon="inline-start" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。
function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) {
if (pending) return <Loader2Icon className="animate-spin" />;
+5
View File
@@ -215,6 +215,9 @@ export const api = {
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
@@ -704,6 +707,8 @@ export const api = {
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
deleteConversations: (ids: number[]) =>
post<{ items: { id: number; ok: boolean; error?: string }[] }>("/conversations/delete/batch", { ids }),
// Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch.
conversationMessages: (id: number, since = 0) =>
get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then(
+37 -1
View File
@@ -233,6 +233,16 @@ function sortMockConversations() {
});
}
function sortMockTasks() {
mockTasks.sort((a, b) => {
const aPinned = a.pinned_at ? 1 : 0;
const bPinned = b.pinned_at ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
if (a.pinned_at !== b.pinned_at) return (b.pinned_at ?? "").localeCompare(a.pinned_at ?? "");
return b.id.localeCompare(a.id, undefined, { numeric: true });
});
}
function normalizedTemplateName(value: unknown): string {
return String(value ?? "")
.trim()
@@ -268,7 +278,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/auth/change-password") return { ok: true };
// ── tasks ──
if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
if (path === "/tasks" && m === "GET") {
sortMockTasks();
return { tasks: mockTasks, active: mockActiveTask };
}
if (path === "/tasks" && m === "POST") {
let suffix = 1;
while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
@@ -436,6 +449,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (index >= 0) mockTaskTemplates.splice(index, 1);
return { deleted: id };
}
if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") {
const task = mockTasks.find((item) => item.id === seg[1]);
if (!task) throw new Error("任务不存在");
if (typeof b.name === "string") task.name = b.name.trim();
if (typeof b.pinned === "boolean") {
task.pinned = b.pinned;
task.pinned_at = b.pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
}
sortMockTasks();
return structuredClone(task);
}
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
const id = seg[1];
const index = mockTasks.findIndex((item) => item.id === id);
@@ -1187,6 +1211,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (index >= 0) mockConversations.splice(index, 1);
return { deleted: id };
}
if (path === "/conversations/delete/batch" && m === "POST") {
const ids = Array.isArray(b.ids)
? [...new Set(b.ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
: [];
const items = ids.map((id) => {
const index = mockConversations.findIndex((item) => item.id === id);
if (index < 0) return { id, ok: false, error: "conversation not found" };
mockConversations.splice(index, 1);
return { id, ok: true };
});
return { items };
}
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
const items = D.conversationMessages[Number(seg[1])] ?? [];
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
+2
View File
@@ -9,6 +9,8 @@ export interface Task {
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
category_id?: number;
category_name?: string;
pinned?: boolean;
pinned_at?: string | null;
description: string;
goal: string;
status: TaskStatus;