feat: group imported threads into project archives
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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