553 lines
20 KiB
TypeScript
553 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import type { DeviceImportDraft, DeviceImportResolution, MasterAgentTask } from "@/lib/boss-data";
|
||
|
||
type ImportDraftResponse = {
|
||
ok: boolean;
|
||
draft?: DeviceImportDraft | null;
|
||
resolution?: DeviceImportResolution | null;
|
||
reviewTask?: MasterAgentTask | null;
|
||
message?: string;
|
||
};
|
||
|
||
type FeedbackTone = "info" | "success" | "error";
|
||
|
||
type Feedback = {
|
||
tone: FeedbackTone;
|
||
text: string;
|
||
};
|
||
|
||
export type DeviceImportDraftViewCopy = {
|
||
statusTitle: string;
|
||
statusBody: string;
|
||
recommendationHint: string;
|
||
resultTitle: string;
|
||
resultBody: string;
|
||
candidateCount: number;
|
||
selectedCount: number;
|
||
recommendedCount: number;
|
||
appliedProjectNames: string[];
|
||
};
|
||
|
||
function isDeviceImportReviewPending(
|
||
draft: DeviceImportDraft | null,
|
||
resolution: DeviceImportResolution | null,
|
||
reviewTask: MasterAgentTask | null,
|
||
) {
|
||
if (!draft || draft.status !== "pending_resolution" || resolution) {
|
||
return false;
|
||
}
|
||
return reviewTask?.status === "queued" || reviewTask?.status === "running";
|
||
}
|
||
|
||
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)),
|
||
}));
|
||
}
|
||
|
||
function joinProjectNames(projectNames: string[]) {
|
||
return projectNames.length > 0 ? projectNames.join("、") : "";
|
||
}
|
||
|
||
export function describeDeviceImportDraft(
|
||
draft: DeviceImportDraft | null,
|
||
resolution: DeviceImportResolution | null,
|
||
reviewTask: MasterAgentTask | null = null,
|
||
): DeviceImportDraftViewCopy {
|
||
const candidateCount = draft?.candidates.length ?? 0;
|
||
const selectedCount = draft?.selectedCandidateIds.length ?? 0;
|
||
const recommendedCount = draft?.candidates.filter((candidate) => candidate.suggestedImport).length ?? 0;
|
||
const appliedProjectNames = draft?.appliedProjectNames ?? [];
|
||
const appliedProjectCount = appliedProjectNames.length;
|
||
|
||
if (!draft) {
|
||
return {
|
||
statusTitle: "等待导入草稿",
|
||
statusBody: "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。",
|
||
recommendationHint: "拿到候选线程后,先从标记为推荐导入的项目开始。",
|
||
resultTitle: "导入结果",
|
||
resultBody: "生成导入建议并应用后,这里会显示真正导入到会话首页的线程。",
|
||
candidateCount,
|
||
selectedCount,
|
||
recommendedCount,
|
||
appliedProjectNames,
|
||
};
|
||
}
|
||
|
||
let statusTitle = "等待勾选";
|
||
let statusBody = "先勾选要导入的线程,再生成导入建议。";
|
||
let resultTitle = "导入建议";
|
||
let resultBody = "应用导入前,这里会先显示主 Agent 风格的导入建议。";
|
||
|
||
switch (draft.status) {
|
||
case "pending_candidates":
|
||
statusTitle = "等待候选线程";
|
||
statusBody = "设备已经就绪,等 heartbeat 带回线程候选后,就可以开始勾选。";
|
||
resultTitle = "导入结果";
|
||
resultBody = "候选线程出现后,这里会显示推荐和建议。";
|
||
break;
|
||
case "pending_selection":
|
||
statusTitle = "等待勾选";
|
||
statusBody = "先勾选想导入的线程,再生成导入建议。";
|
||
break;
|
||
case "pending_resolution":
|
||
if (isDeviceImportReviewPending(draft, resolution, reviewTask)) {
|
||
statusTitle = "主 Agent 审核中";
|
||
statusBody = "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。";
|
||
} else if (reviewTask?.status === "failed") {
|
||
statusTitle = "建议生成失败";
|
||
statusBody = "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。";
|
||
} else {
|
||
statusTitle = "建议生成中";
|
||
statusBody = "勾选已保存,接下来会生成导入建议。";
|
||
}
|
||
resultTitle = "导入建议";
|
||
resultBody =
|
||
reviewTask?.status === "failed"
|
||
? "导入建议生成失败后,可以直接重新生成,不需要重新勾选。"
|
||
: "导入建议生成后,会先显示每个线程的处理方式和原因。";
|
||
break;
|
||
case "resolved":
|
||
statusTitle = "建议已生成";
|
||
statusBody = "可以先看建议,再点应用导入把线程落成会话窗口。";
|
||
resultTitle = "导入建议";
|
||
resultBody = resolution?.summary ?? "主 Agent 已给出导入建议。";
|
||
break;
|
||
case "applied":
|
||
statusTitle = "已导入";
|
||
statusBody =
|
||
appliedProjectCount > 0
|
||
? `已导入 ${appliedProjectCount} 个线程:${joinProjectNames(appliedProjectNames)}。`
|
||
: "导入已完成,线程已经落到会话首页。";
|
||
resultTitle = "应用结果";
|
||
resultBody =
|
||
appliedProjectCount > 0
|
||
? `已把 ${appliedProjectCount} 个线程导入到会话首页。`
|
||
: "应用导入后,线程已经出现在会话首页。";
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
const recommendationHint =
|
||
recommendedCount > 0
|
||
? `推荐 ${recommendedCount} 项,优先勾选带“推荐导入”的线程。`
|
||
: candidateCount > 0
|
||
? "当前没有显式推荐项,按最近活跃度挑选也可以。"
|
||
: "当前还没有可选线程。";
|
||
|
||
return {
|
||
statusTitle,
|
||
statusBody,
|
||
recommendationHint,
|
||
resultTitle,
|
||
resultBody,
|
||
candidateCount,
|
||
selectedCount,
|
||
recommendedCount,
|
||
appliedProjectNames,
|
||
};
|
||
}
|
||
|
||
export function DeviceImportDraftManager({
|
||
deviceId,
|
||
deviceName,
|
||
}: {
|
||
deviceId: string;
|
||
deviceName?: string;
|
||
}) {
|
||
const router = useRouter();
|
||
const [loading, setLoading] = useState(false);
|
||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
|
||
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
|
||
const [reviewTask, setReviewTask] = useState<MasterAgentTask | null>(null);
|
||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
|
||
|
||
const loadDraft = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" });
|
||
const data = (await response.json()) as ImportDraftResponse;
|
||
setDraft(data.draft ?? null);
|
||
setResolution(data.resolution ?? null);
|
||
setReviewTask(data.reviewTask ?? null);
|
||
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
|
||
setFeedback(
|
||
data.ok
|
||
? null
|
||
: { tone: "error", text: data.message ?? "导入草稿加载失败" },
|
||
);
|
||
} catch (error) {
|
||
setFeedback({
|
||
tone: "error",
|
||
text: error instanceof Error ? error.message : "导入草稿加载失败",
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [deviceId]);
|
||
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(() => {
|
||
void loadDraft();
|
||
}, 0);
|
||
return () => window.clearTimeout(timer);
|
||
}, [loadDraft]);
|
||
|
||
useEffect(() => {
|
||
if (!isDeviceImportReviewPending(draft, resolution, reviewTask)) {
|
||
return;
|
||
}
|
||
const timer = window.setTimeout(() => {
|
||
void loadDraft();
|
||
}, 2000);
|
||
return () => window.clearTimeout(timer);
|
||
}, [draft, resolution, reviewTask, loadDraft]);
|
||
|
||
const groups = useMemo(() => groupCandidates(draft), [draft]);
|
||
const copy = useMemo(
|
||
() => describeDeviceImportDraft(draft, resolution, reviewTask),
|
||
[draft, resolution, reviewTask],
|
||
);
|
||
|
||
function toggle(candidateId: string) {
|
||
setSelectedCandidateIds((current) =>
|
||
current.includes(candidateId)
|
||
? current.filter((item) => item !== candidateId)
|
||
: [...current, candidateId],
|
||
);
|
||
}
|
||
|
||
async function clearSelection() {
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ selectedCandidateIds: [] }),
|
||
});
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message?: string;
|
||
draft?: DeviceImportDraft;
|
||
};
|
||
if (!result.ok) {
|
||
setFeedback({ tone: "error", text: result.message ?? "清空勾选失败" });
|
||
return;
|
||
}
|
||
setDraft(result.draft ?? draft);
|
||
setResolution(null);
|
||
setReviewTask(null);
|
||
setSelectedCandidateIds([]);
|
||
setFeedback({ tone: "success", text: "已清空当前勾选,你可以重新选择要导入的线程。" });
|
||
} catch (error) {
|
||
setFeedback({
|
||
tone: "error",
|
||
text: error instanceof Error ? error.message : "清空勾选失败",
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function reviewSelection() {
|
||
setLoading(true);
|
||
try {
|
||
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) {
|
||
setFeedback({ tone: "error", text: selectResult.message ?? "勾选保存失败" });
|
||
return;
|
||
}
|
||
setDraft(selectResult.draft ?? draft);
|
||
setResolution(null);
|
||
setReviewTask(null);
|
||
setSelectedCandidateIds(selectResult.draft?.selectedCandidateIds ?? selectedCandidateIds);
|
||
|
||
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;
|
||
reviewTask?: MasterAgentTask;
|
||
task?: MasterAgentTask;
|
||
};
|
||
if (!reviewResult.ok) {
|
||
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败,已保留当前勾选。" });
|
||
return;
|
||
}
|
||
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
|
||
setResolution(reviewResult.resolution ?? null);
|
||
setReviewTask(reviewResult.reviewTask ?? reviewResult.task ?? null);
|
||
setSelectedCandidateIds(
|
||
reviewResult.draft?.selectedCandidateIds ??
|
||
selectResult.draft?.selectedCandidateIds ??
|
||
selectedCandidateIds,
|
||
);
|
||
setFeedback({
|
||
tone: reviewResult.resolution ? "success" : "info",
|
||
text: reviewResult.resolution
|
||
? "已生成导入建议,先看推荐理由再应用导入。"
|
||
: "已提交给主 Agent 审核,建议生成后会自动刷新。",
|
||
});
|
||
} catch (error) {
|
||
setFeedback({
|
||
tone: "error",
|
||
text: error instanceof Error ? error.message : "导入建议生成失败",
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function applyResolution() {
|
||
setLoading(true);
|
||
try {
|
||
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;
|
||
};
|
||
if (!result.ok) {
|
||
setFeedback({ tone: "error", text: result.message ?? "导入应用失败" });
|
||
return;
|
||
}
|
||
setDraft(result.draft ?? draft);
|
||
setResolution(result.resolution ?? resolution);
|
||
setReviewTask(null);
|
||
setSelectedCandidateIds(result.draft?.selectedCandidateIds ?? draft?.selectedCandidateIds ?? []);
|
||
setFeedback({
|
||
tone: "success",
|
||
text: result.draft?.appliedProjectNames?.length
|
||
? `已导入 ${result.draft.appliedProjectNames.length} 个线程:${joinProjectNames(result.draft.appliedProjectNames)}。`
|
||
: "已把选中的项目线程导入到会话首页。",
|
||
});
|
||
router.refresh();
|
||
} catch (error) {
|
||
setFeedback({
|
||
tone: "error",
|
||
text: error instanceof Error ? error.message : "导入应用失败",
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
const candidateCount = copy.candidateCount;
|
||
const recommendedCount = copy.recommendedCount;
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<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="shrink-0 rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]"
|
||
>
|
||
{loading ? "刷新中" : "刷新"}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
<div className="font-semibold text-[#111111]">{copy.statusTitle}</div>
|
||
<div className="mt-1 text-[#57606A]">{copy.statusBody}</div>
|
||
<div className="mt-2 text-[#8C8C8C]">
|
||
候选 {candidateCount} · 已选 {selectedCandidateIds.length} · 推荐 {recommendedCount}
|
||
</div>
|
||
<div className="mt-2 text-[#8C8C8C]">{copy.recommendationHint}</div>
|
||
</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}
|
||
{reviewTask ? (
|
||
<>
|
||
<br />
|
||
审核任务:{reviewTask.status}
|
||
</>
|
||
) : null}
|
||
{draft.status === "pending_candidates" ? (
|
||
<>
|
||
<br />
|
||
设备已在线,但还没有发现可导入线程。
|
||
</>
|
||
) : null}
|
||
</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="flex items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
|
||
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 个线程</div>
|
||
</div>
|
||
{group.items.some((candidate) => candidate.suggestedImport) ? (
|
||
<span className="shrink-0 rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
|
||
推荐导入
|
||
</span>
|
||
) : null}
|
||
</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>
|
||
{candidate.suggestedImport ? (
|
||
<div className="mt-1 text-[11px] font-semibold text-[#215B39]">
|
||
这是推荐导入项
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
{selected ? (
|
||
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
|
||
已选
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
<div className="font-semibold text-[#111111]">{copy.resultTitle}</div>
|
||
<div className="mt-1 text-[#57606A]">{copy.resultBody}</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}
|
||
|
||
{draft?.appliedProjectNames?.length ? (
|
||
<div className="rounded-2xl border border-[#DCEFE5] bg-[#F3FBF6] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
|
||
<div className="font-semibold text-[#111111]">已导入到会话首页</div>
|
||
<div className="mt-1">
|
||
{draft.appliedProjectNames.length} 个线程:{joinProjectNames(draft.appliedProjectNames)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void reviewSelection()}
|
||
disabled={loading || selectedCandidateIds.length === 0 || isDeviceImportReviewPending(draft, resolution, reviewTask)}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||
>
|
||
{isDeviceImportReviewPending(draft, resolution, reviewTask)
|
||
? "主 Agent 审核中"
|
||
: draft?.status === "resolved" || draft?.status === "applied" || reviewTask?.status === "failed"
|
||
? "重新生成导入建议"
|
||
: "生成导入建议"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void clearSelection()}
|
||
disabled={loading || selectedCandidateIds.length === 0}
|
||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
|
||
>
|
||
清空勾选
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void applyResolution()}
|
||
disabled={loading || !resolution || draft?.status !== "resolved"}
|
||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
|
||
>
|
||
{draft?.status === "applied" ? "已导入" : "应用导入"}
|
||
</button>
|
||
</div>
|
||
|
||
{feedback ? (
|
||
<div
|
||
className={
|
||
feedback.tone === "error"
|
||
? "rounded-2xl bg-[#FDECEC] px-4 py-3 text-[12px] leading-6 text-[#9E1B1B]"
|
||
: feedback.tone === "success"
|
||
? "rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]"
|
||
: "rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]"
|
||
}
|
||
>
|
||
{feedback.text}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|