feat: group imported threads into project archives
This commit is contained in:
243
src/components/device-import-draft-manager.tsx
Normal file
243
src/components/device-import-draft-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user