feat: group imported threads into project archives

This commit is contained in:
kris
2026-03-30 13:50:26 +08:00
parent 98dd0e3cd5
commit 03ac40f427
23 changed files with 1207 additions and 83 deletions

View File

@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import clsx from "clsx";
import { sendAppLog } from "@/components/app-runtime";
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
import {
clearNativeSessionSnapshot,
currentAppLocation,
@@ -353,6 +354,10 @@ function ConversationActionButtons({
const router = useRouter();
const [loading, setLoading] = useState<"toggle_pin" | "mark_read" | null>(null);
if (conversation.conversationType === "folder_archive") {
return <div className="min-h-[24px]" />;
}
async function runAction(action: "toggle_pin" | "mark_read") {
setLoading(action);
await fetch(conversationActionsPath(conversation.projectId), {
@@ -405,7 +410,14 @@ export function ConversationList({
<div className="mb-2 flex justify-end">
<ConversationActionButtons conversation={conversation} />
</div>
<Link href={`/conversations/${conversation.projectId}`} className="flex items-start gap-3">
<Link
href={
conversation.conversationType === "folder_archive" && conversation.folderKey
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
: `/conversations/${conversation.projectId}`
}
className="flex items-start gap-3"
>
<AvatarStack
primary={conversation.avatar.primary}
secondary={conversation.avatar.secondary}
@@ -416,20 +428,28 @@ export function ConversationList({
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[17px] font-medium text-[#111111]">
{conversation.projectTitle}
{conversation.conversationType === "folder_archive"
? conversation.threadTitle
: conversation.projectTitle}
</div>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
riskBadgeColor(conversation.riskLevel),
)}
>
{conversation.riskLevel === "high"
? "高风险"
: conversation.riskLevel === "medium"
? "关注"
: "稳定"}
</span>
{conversation.conversationType === "folder_archive" ? (
<span className="rounded-full bg-[#F4F5F7] px-2 py-0.5 text-[10px] font-semibold text-[#57606A]">
{conversation.threadCount ?? 0} 线
</span>
) : (
<span
className={clsx(
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
riskBadgeColor(conversation.riskLevel),
)}
>
{conversation.riskLevel === "high"
? "高风险"
: conversation.riskLevel === "medium"
? "关注"
: "稳定"}
</span>
)}
{conversation.unreadCount > 0 ? (
<span className="rounded-full bg-[#FF4D4F] px-2 py-0.5 text-[10px] font-semibold text-white">
{conversation.unreadCount}
@@ -437,7 +457,9 @@ export function ConversationList({
) : null}
</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">
{conversation.deviceNamesPreview.join(" / ")}
{conversation.conversationType === "folder_archive"
? conversation.folderLabel
: conversation.deviceNamesPreview.join(" / ")}
</div>
<div className="mt-1 truncate text-[14px] text-[#57606A]">
{conversation.preview}
@@ -1450,6 +1472,9 @@ export function DeviceEnrollmentBuilder() {
{configSnippet}
</pre>
) : null}
{result.device?.id ? (
<DeviceImportDraftManager deviceId={result.device.id} deviceName={result.device.name} />
) : null}
</div>
);
}

View File

@@ -0,0 +1,243 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { DeviceImportDraft, DeviceImportResolution } from "@/lib/boss-data";
type ImportDraftResponse = {
ok: boolean;
draft?: DeviceImportDraft | null;
resolution?: DeviceImportResolution | null;
message?: string;
};
function groupCandidates(draft: DeviceImportDraft | null) {
const groups = new Map<
string,
Array<DeviceImportDraft["candidates"][number]>
>();
for (const candidate of draft?.candidates ?? []) {
const key = candidate.codexFolderRef?.trim() || candidate.folderRef?.trim() || candidate.folderName;
const bucket = groups.get(key) ?? [];
bucket.push(candidate);
groups.set(key, bucket);
}
return [...groups.entries()].map(([key, items]) => ({
key,
folderName: items[0]?.folderName ?? "未命名项目",
items: [...items].sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)),
}));
}
export function DeviceImportDraftManager({
deviceId,
deviceName,
}: {
deviceId: string;
deviceName?: string;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const loadDraft = useCallback(async () => {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" });
const data = (await response.json()) as ImportDraftResponse;
setLoading(false);
setDraft(data.draft ?? null);
setResolution(data.resolution ?? null);
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
setMessage(data.ok ? "" : data.message ?? "导入草稿加载失败");
}, [deviceId]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadDraft();
}, 0);
return () => window.clearTimeout(timer);
}, [loadDraft]);
const groups = useMemo(() => groupCandidates(draft), [draft]);
function toggle(candidateId: string) {
setSelectedCandidateIds((current) =>
current.includes(candidateId)
? current.filter((item) => item !== candidateId)
: [...current, candidateId],
);
}
async function reviewSelection() {
setLoading(true);
const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedCandidateIds }),
});
const selectResult = (await selectResponse.json()) as { ok: boolean; message?: string; draft?: DeviceImportDraft };
if (!selectResult.ok) {
setLoading(false);
setMessage(selectResult.message ?? "勾选保存失败");
return;
}
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const reviewResult = (await reviewResponse.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!reviewResult.ok) {
setMessage(reviewResult.message ?? "导入建议生成失败");
return;
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
setResolution(reviewResult.resolution ?? null);
setMessage("已生成导入建议。");
}
async function applyResolution() {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!result.ok) {
setMessage(result.message ?? "导入应用失败");
return;
}
setDraft(result.draft ?? draft);
setResolution(result.resolution ?? resolution);
setMessage("已把选中的项目线程导入到会话首页。");
router.refresh();
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-[16px] font-semibold text-[#111111]"> Codex </div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{deviceName ?? deviceId} heartbeat 线
</div>
</div>
<button
type="button"
onClick={() => void loadDraft()}
disabled={loading}
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]"
>
{loading ? "刷新中" : "刷新"}
</button>
</div>
{draft ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
线{draft.candidates.length}
<br />
{draft.status}
<br />
{selectedCandidateIds.length}
</div>
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
稿线
</div>
)}
{groups.map((group) => (
<div key={group.key} className="rounded-2xl border border-[#EAECEF] bg-[#FCFCFD] px-3 py-3">
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 线</div>
<div className="mt-3 space-y-2">
{group.items.map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.candidateId);
return (
<label
key={candidate.candidateId}
className="flex cursor-pointer items-start gap-3 rounded-2xl border border-[#E5E5EA] bg-white px-3 py-3"
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-[#07C160]"
checked={selected}
onChange={() => toggle(candidate.candidateId)}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium text-[#111111]">
{candidate.threadDisplayName}
</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{candidate.lastActiveAt}
</div>
</div>
{candidate.suggestedImport ? (
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
</span>
) : null}
</label>
);
})}
</div>
</div>
))}
{resolution ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
<div className="font-semibold text-[#111111]">{resolution.summary}</div>
<div className="mt-2 space-y-1">
{resolution.items.map((item) => (
<div key={item.candidateId}>
{item.threadDisplayName} · {item.folderName} · {item.action}
</div>
))}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void reviewSelection()}
disabled={loading || selectedCandidateIds.length === 0}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
<button
type="button"
onClick={() => void applyResolution()}
disabled={loading || !resolution || draft?.status === "applied"}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
>
{draft?.status === "applied" ? "已导入" : "应用导入"}
</button>
</div>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
{message}
</div>
) : null}
</div>
);
}