2022 lines
66 KiB
TypeScript
2022 lines
66 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
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,
|
||
isNativeBossApp,
|
||
persistNativeSessionSnapshot,
|
||
popAppHistoryEntry,
|
||
resolveAppBackAction,
|
||
} from "@/lib/boss-app-client";
|
||
import {
|
||
extractApprovedTargetProjectIds,
|
||
summarizeDispatchPlan,
|
||
} from "@/lib/dispatch-plan-ui";
|
||
import type {
|
||
Device,
|
||
DeviceEnrollment,
|
||
GoalItem,
|
||
MasterIdentitySummary,
|
||
Message,
|
||
OtaUpdate,
|
||
OtaUpdateLog,
|
||
OpsRepairTicket,
|
||
OpsRepairVerification,
|
||
ThreadContextSnapshot,
|
||
UserProfile,
|
||
UserSettings,
|
||
} from "@/lib/boss-data";
|
||
import type { ConversationItem } from "@/lib/boss-projections";
|
||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
||
|
||
function formatClock(value: string) {
|
||
return formatTimestampLabel(value);
|
||
}
|
||
|
||
function formatBytes(value?: number) {
|
||
if (!value || value <= 0) return "未知大小";
|
||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||
return `${value} B`;
|
||
}
|
||
|
||
function boundDeviceIdFromDom() {
|
||
return document.body.dataset.boundDeviceId || "mac-studio";
|
||
}
|
||
|
||
async function waitForLoginSessionReady(nativeClient: boolean) {
|
||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||
const response = await fetch("/api/auth/session", {
|
||
cache: "no-store",
|
||
headers: nativeClient ? { "x-boss-native-app": "1" } : undefined,
|
||
}).catch(() => null);
|
||
if (response?.ok) {
|
||
return true;
|
||
}
|
||
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function navigateToConversations(router: ReturnType<typeof useRouter>) {
|
||
router.replace("/conversations", { scroll: false });
|
||
router.refresh();
|
||
window.setTimeout(() => {
|
||
if (window.location.pathname !== "/conversations") {
|
||
window.location.replace("/conversations");
|
||
}
|
||
}, 180);
|
||
}
|
||
|
||
export function AppShell({
|
||
children,
|
||
bottomNav = true,
|
||
}: {
|
||
children: React.ReactNode;
|
||
bottomNav?: boolean;
|
||
}) {
|
||
return (
|
||
<main className="min-h-[100dvh] md:px-6 md:py-6">
|
||
<div
|
||
className={clsx(
|
||
"flex w-full flex-col bg-[rgba(247,249,245,0.90)] backdrop-blur-[18px]",
|
||
bottomNav ? "min-h-[100dvh] overflow-hidden md:h-[calc(100dvh-48px)]" : "min-h-[100dvh]",
|
||
"md:mx-auto md:max-w-[430px] md:overflow-hidden md:rounded-[32px] md:border md:border-white/70 md:shadow-[0_20px_48px_rgba(22,31,44,0.14)]",
|
||
)}
|
||
>
|
||
<div
|
||
className={clsx(
|
||
"flex min-h-0 flex-1 flex-col",
|
||
bottomNav ? "overflow-y-auto overscroll-y-contain boss-scrollbar" : "",
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
{bottomNav ? <BottomNav /> : null}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export function StatusBar() {
|
||
return <div className="shrink-0" style={{ height: "max(12px, env(safe-area-inset-top))" }} />;
|
||
}
|
||
|
||
export function PageNav({
|
||
title,
|
||
backHref,
|
||
rightLabel,
|
||
rightHref,
|
||
rightNode,
|
||
}: {
|
||
title: string;
|
||
backHref?: string;
|
||
rightLabel?: string;
|
||
rightHref?: string;
|
||
rightNode?: React.ReactNode;
|
||
}) {
|
||
const router = useRouter();
|
||
|
||
async function handleBack() {
|
||
const currentPath = currentAppLocation();
|
||
const action = resolveAppBackAction(currentPath, backHref);
|
||
if (action.mode === "history") {
|
||
popAppHistoryEntry(currentPath);
|
||
router.back();
|
||
return;
|
||
}
|
||
if (action.mode === "replace") {
|
||
popAppHistoryEntry(currentPath);
|
||
router.replace(action.target, { scroll: false });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center justify-between px-[18px] py-2">
|
||
{backHref ? (
|
||
<button type="button" onClick={() => void handleBack()} className="text-[15px] text-[#57606A]">
|
||
{"< 返回"}
|
||
</button>
|
||
) : (
|
||
<span className="w-12" />
|
||
)}
|
||
<h1 className="text-[17px] font-semibold text-[#111111]">{title}</h1>
|
||
{rightNode ? (
|
||
rightNode
|
||
) : rightLabel ? (
|
||
<Link href={rightHref ?? "#"} className="text-[15px] font-semibold text-[#07C160]">
|
||
{rightLabel}
|
||
</Link>
|
||
) : (
|
||
<span className="w-12" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function BottomNav() {
|
||
const pathname = usePathname();
|
||
const items = [
|
||
{ href: "/conversations", label: "会话", icon: "◌" },
|
||
{ href: "/devices", label: "设备", icon: "◫" },
|
||
{ href: "/me", label: "我的", icon: "◍" },
|
||
];
|
||
|
||
return (
|
||
<div className="mt-auto shrink-0 border-t border-[#E5E5EA] bg-[rgba(255,255,255,0.94)] backdrop-blur-xl">
|
||
<div
|
||
className="flex items-center justify-between px-[18px] pt-[8px]"
|
||
style={{ minHeight: "64px", paddingBottom: "max(12px, env(safe-area-inset-bottom))" }}
|
||
>
|
||
{items.map((item) => {
|
||
const active = pathname.startsWith(item.href);
|
||
return (
|
||
<Link
|
||
key={item.href}
|
||
href={item.href}
|
||
className="flex w-full flex-col items-center gap-1 text-xs"
|
||
>
|
||
<span className={clsx("text-lg", active ? "text-[#07C160]" : "text-[#9A9A9A]")}>
|
||
{item.icon}
|
||
</span>
|
||
<span className={clsx(active ? "text-[#07C160]" : "text-[#9A9A9A]")}>
|
||
{item.label}
|
||
</span>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function HeaderTitle({
|
||
title,
|
||
extra,
|
||
}: {
|
||
title: string;
|
||
extra?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between px-[18px] py-2">
|
||
<h1 className="text-[22px] font-semibold text-[#111111]">{title}</h1>
|
||
{extra}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LogoutButton({
|
||
label = "退出登录",
|
||
compact = false,
|
||
}: {
|
||
label?: string;
|
||
compact?: boolean;
|
||
}) {
|
||
const router = useRouter();
|
||
const [loading, setLoading] = useState(false);
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function handleLogout() {
|
||
setLoading(true);
|
||
const response = await fetch("/api/auth/logout", {
|
||
method: "POST",
|
||
});
|
||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||
setLoading(false);
|
||
setMessage(result.message ?? (result.ok ? "已退出登录。" : "退出失败。"));
|
||
if (result.ok) {
|
||
await clearNativeSessionSnapshot();
|
||
router.push("/auth/login");
|
||
router.refresh();
|
||
}
|
||
}
|
||
|
||
if (compact) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleLogout()}
|
||
disabled={loading}
|
||
className="text-[15px] font-semibold text-[#07C160]"
|
||
>
|
||
{loading ? "退出中" : label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[16px] font-medium text-[#111111]">{label}</div>
|
||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||
清除当前会话,回到登录页重新选择账号或验证码方式。
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleLogout()}
|
||
disabled={loading}
|
||
className="rounded-full bg-[#111111] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
{loading ? "退出中" : "立即退出"}
|
||
</button>
|
||
</div>
|
||
{message ? (
|
||
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AvatarStack({
|
||
primary,
|
||
secondary,
|
||
overflowCount,
|
||
}: {
|
||
primary: string;
|
||
secondary?: string;
|
||
overflowCount?: number;
|
||
}) {
|
||
if (!secondary) {
|
||
return (
|
||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#DFF4E8] text-lg font-semibold text-[#215B39]">
|
||
{primary}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="relative h-12 w-12">
|
||
<div className="absolute left-0 top-1 flex h-8 w-8 items-center justify-center rounded-xl bg-[#DFF4E8] text-sm font-semibold text-[#215B39]">
|
||
{primary}
|
||
</div>
|
||
<div className="absolute bottom-0 right-0 flex h-8 w-8 items-center justify-center rounded-xl border-2 border-white bg-[#EAF0FF] text-sm font-semibold text-[#3B5CCC]">
|
||
{overflowCount ? `+${overflowCount}` : secondary}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ContextRing({
|
||
value,
|
||
label,
|
||
level,
|
||
}: {
|
||
value: number;
|
||
label: string;
|
||
level?: "safe" | "watch" | "urgent" | "critical";
|
||
}) {
|
||
const color =
|
||
level === "critical"
|
||
? "#FF4D4F"
|
||
: level === "urgent"
|
||
? "#FF8A00"
|
||
: level === "watch"
|
||
? "#1C7ED6"
|
||
: "#07C160";
|
||
const background = `conic-gradient(${color} ${value * 3.6}deg, #D9D9D9 0deg)`;
|
||
|
||
return (
|
||
<div className="flex items-center gap-2 text-[11px] text-[#8C8C8C]">
|
||
<span className="relative block h-[14px] w-[14px] rounded-full" style={{ background }}>
|
||
<span className="absolute inset-[2px] rounded-full bg-white" />
|
||
</span>
|
||
<span>{label}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function riskBadgeColor(level: ConversationItem["riskLevel"]) {
|
||
switch (level) {
|
||
case "high":
|
||
return "bg-[#FFF1F0] text-[#CF1322]";
|
||
case "medium":
|
||
return "bg-[#FFF7E6] text-[#D46B08]";
|
||
default:
|
||
return "bg-[#F6FFED] text-[#389E0D]";
|
||
}
|
||
}
|
||
|
||
function conversationActionsPath(projectId: string) {
|
||
return `/api/v1/conversations/${projectId}/actions`;
|
||
}
|
||
|
||
function ConversationActionButtons({
|
||
conversation,
|
||
}: {
|
||
conversation: ConversationItem;
|
||
}) {
|
||
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), {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action }),
|
||
});
|
||
setLoading(null);
|
||
router.refresh();
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
{conversation.projectId !== "master-agent" ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void runAction("toggle_pin")}
|
||
disabled={loading === "toggle_pin"}
|
||
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[11px] text-[#57606A]"
|
||
>
|
||
{conversation.manualPinned ? "取消置顶" : "置顶"}
|
||
</button>
|
||
) : null}
|
||
{conversation.unreadCount > 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void runAction("mark_read")}
|
||
disabled={loading === "mark_read"}
|
||
className="rounded-full border border-[#E5E5EA] px-3 py-1 text-[11px] text-[#57606A]"
|
||
>
|
||
已读
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ConversationList({
|
||
conversations,
|
||
}: {
|
||
conversations: ConversationItem[];
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-1 px-[18px] pb-5">
|
||
{conversations.map((conversation) => (
|
||
<div
|
||
key={conversation.conversationId}
|
||
className="rounded-2xl px-1 py-3 transition hover:bg-white/70"
|
||
>
|
||
<div className="mb-2 flex justify-end">
|
||
<ConversationActionButtons conversation={conversation} />
|
||
</div>
|
||
<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}
|
||
overflowCount={conversation.avatar.overflowCount}
|
||
/>
|
||
<div className="min-w-0 flex-1 border-b border-[#EFEFF4] pb-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<div className="truncate text-[17px] font-medium text-[#111111]">
|
||
{conversation.conversationType === "folder_archive"
|
||
? conversation.threadTitle
|
||
: conversation.projectTitle}
|
||
</div>
|
||
{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}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-1 text-[13px] text-[#8C8C8C]">
|
||
{conversation.conversationType === "folder_archive"
|
||
? conversation.folderLabel
|
||
: conversation.deviceNamesPreview.join(" / ")}
|
||
</div>
|
||
<div className="mt-1 truncate text-[14px] text-[#57606A]">
|
||
{conversation.preview}
|
||
</div>
|
||
</div>
|
||
<div className="flex min-w-[98px] flex-col items-end gap-1">
|
||
<div className="min-h-[18px] text-[11px] text-[#07C160]">
|
||
{conversation.projectId === "master-agent"
|
||
? "置顶"
|
||
: conversation.manualPinned
|
||
? "置顶"
|
||
: ""}
|
||
</div>
|
||
<div className="text-[12px] text-[#8C8C8C]">
|
||
{conversation.latestReplyLabel}
|
||
</div>
|
||
{conversation.contextBudgetIndicator.visible &&
|
||
conversation.contextBudgetIndicator.percent !== undefined ? (
|
||
<ContextRing
|
||
value={conversation.contextBudgetIndicator.percent}
|
||
label={`${conversation.contextBudgetIndicator.percent}%`}
|
||
level={conversation.contextBudgetIndicator.level}
|
||
/>
|
||
) : (
|
||
<div className="min-h-[16px] text-[11px] text-[#8C8C8C]">
|
||
{conversation.activeDeviceCount > 1
|
||
? `${conversation.activeDeviceCount} 台协作`
|
||
: ""}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function DeviceList({ devices }: { devices: Device[] }) {
|
||
const router = useRouter();
|
||
return (
|
||
<div className="flex flex-col gap-3 px-[18px] pb-5">
|
||
{devices.map((device) => (
|
||
<button
|
||
key={device.id}
|
||
type="button"
|
||
onClick={() => router.push(`/devices?device=${device.id}`)}
|
||
className="flex w-full items-start gap-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-left"
|
||
>
|
||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#DFF4E8] text-lg font-semibold text-[#215B39]">
|
||
{device.avatar}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<div className="text-[16px] font-medium text-[#111111]">{device.name}</div>
|
||
<span
|
||
className={clsx(
|
||
"h-2.5 w-2.5 rounded-full",
|
||
device.status === "online"
|
||
? "bg-[#07C160]"
|
||
: device.status === "abnormal"
|
||
? "bg-[#FF4D4F]"
|
||
: "bg-[#B8B8B8]",
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="mt-1 text-[13px] text-[#8C8C8C]">{device.account}</div>
|
||
<div className="mt-2 text-[13px] text-[#57606A]">
|
||
项目:{device.projects.join(" · ")}
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-4 text-[12px] text-[#57606A]">
|
||
<span>5h:{device.quota5h}%</span>
|
||
<span>7d:{device.quota7d}%</span>
|
||
<span>最近:{formatClock(device.lastSeenAt)}</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function DeviceEditorCard({
|
||
device,
|
||
relatedThreads,
|
||
activeEnrollment,
|
||
}: {
|
||
device: Device;
|
||
relatedThreads: ThreadContextSnapshot[];
|
||
activeEnrollment?: DeviceEnrollment;
|
||
}) {
|
||
const router = useRouter();
|
||
const [name, setName] = useState(device.name);
|
||
const [avatar, setAvatar] = useState(device.avatar);
|
||
const [account, setAccount] = useState(device.account);
|
||
const [status, setStatus] = useState<Device["status"]>(device.status);
|
||
const [endpoint, setEndpoint] = useState(device.endpoint ?? "");
|
||
const [note, setNote] = useState(device.note ?? "");
|
||
const [projects, setProjects] = useState(device.projects.join(", "));
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function save() {
|
||
const response = await fetch(`/api/v1/devices/${device.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name,
|
||
avatar,
|
||
account,
|
||
status,
|
||
endpoint,
|
||
note,
|
||
projects: projects
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
}),
|
||
});
|
||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||
setMessage(result.ok ? "设备信息已更新。" : result.message ?? "更新失败");
|
||
if (result.ok) 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 className="text-[16px] font-semibold text-[#111111]">设备详情</div>
|
||
<div className="text-[12px] text-[#8C8C8C]">ID: {device.id}</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="设备名称" value={name} onChange={setName} />
|
||
<Field label="头像缩写" value={avatar} onChange={setAvatar} />
|
||
</div>
|
||
<Field label="账号" value={account} onChange={setAccount} />
|
||
<Field label="Endpoint" value={endpoint} onChange={setEndpoint} />
|
||
<Field label="备注" value={note} onChange={setNote} />
|
||
<Field label="项目列表(逗号分隔)" value={projects} onChange={setProjects} />
|
||
<div className="space-y-1">
|
||
<div className="text-[12px] text-[#8C8C8C]">当前状态</div>
|
||
<div className="flex gap-2">
|
||
{(["online", "abnormal", "offline"] as const).map((item) => (
|
||
<button
|
||
key={item}
|
||
type="button"
|
||
onClick={() => setStatus(item)}
|
||
className={clsx(
|
||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||
status === item ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void save()}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
保存设备信息
|
||
</button>
|
||
{message ? (
|
||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
{activeEnrollment ? (
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
Pairing code: <span className="font-semibold text-[#111111]">{activeEnrollment.pairingCode}</span>
|
||
<br />
|
||
Token: <span className="font-semibold text-[#111111]">{activeEnrollment.token}</span>
|
||
<br />
|
||
过期时间:{formatClock(activeEnrollment.expiresAt)}
|
||
</div>
|
||
) : null}
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
活跃线程:{relatedThreads.length}
|
||
<br />
|
||
{relatedThreads.map((thread) => `${thread.title} (${thread.contextBudgetRemainingPct}%)`).join(";") ||
|
||
"当前无活跃线程。"}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ProfileHero({ user }: { user: UserProfile }) {
|
||
return (
|
||
<div className="rounded-2xl bg-white px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#DFF4E8] text-xl font-semibold text-[#215B39]">
|
||
{user.avatar}
|
||
</div>
|
||
<div>
|
||
<div className="text-[18px] font-semibold">{user.name}</div>
|
||
<div className="mt-1 text-[13px] text-[#8C8C8C]">{user.accountType}</div>
|
||
<div className="mt-1 text-[12px] text-[#57606A]">账号:{user.account}</div>
|
||
<div className="mt-1 text-[12px] text-[#57606A]">
|
||
绑定节点:{user.boundCodexNodeLabel ?? "未绑定"}
|
||
</div>
|
||
<div className="mt-1 text-[12px] text-[#57606A]">二维码:{user.qrCodeValue}</div>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 text-right">
|
||
<div className="rounded-full bg-[#FFF5E8] px-3 py-1 text-[12px] font-semibold text-[#B54708]">
|
||
{user.roleLabel}
|
||
</div>
|
||
<div className="rounded-xl border border-[#E5E5EA] px-3 py-2 text-lg text-[#57606A]">⌘</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MenuRow({
|
||
href,
|
||
title,
|
||
description,
|
||
badge,
|
||
}: {
|
||
href: string;
|
||
title: string;
|
||
description: string;
|
||
badge?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<Link
|
||
href={href}
|
||
className="flex items-center justify-between rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4"
|
||
>
|
||
<div>
|
||
<div className="text-[16px] font-medium text-[#111111]">{title}</div>
|
||
<div className="mt-1 text-[12px] text-[#8C8C8C]">{description}</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[#8C8C8C]">
|
||
{badge}
|
||
<span>></span>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function kindLabel(kind?: Message["kind"]) {
|
||
switch (kind) {
|
||
case "voice_intent":
|
||
return "语音";
|
||
case "image_intent":
|
||
return "图片";
|
||
case "video_intent":
|
||
return "视频";
|
||
case "forward_notice":
|
||
return "转发";
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function ChatBubble({ message }: { message: Message }) {
|
||
const mine = message.sender === "user";
|
||
const green = message.sender === "master";
|
||
const tag = kindLabel(message.kind);
|
||
|
||
return (
|
||
<div className={clsx("flex", mine ? "justify-end" : "justify-start")}>
|
||
<div className="max-w-[82%]">
|
||
<div className="mb-1 px-1 text-[11px] text-[#8C8C8C]">
|
||
{message.senderLabel} · {formatClock(message.sentAt)}
|
||
</div>
|
||
<div
|
||
className={clsx(
|
||
"rounded-[18px] px-4 py-3 text-[15px] leading-6",
|
||
mine
|
||
? "bg-[#07C160] text-white"
|
||
: green
|
||
? "bg-[#EAF7F0] text-[#215B39]"
|
||
: "bg-white text-[#111111]",
|
||
)}
|
||
>
|
||
{tag ? (
|
||
<div className="mb-2 text-[11px] font-semibold opacity-80">{tag}</div>
|
||
) : null}
|
||
{message.body}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ProjectHeaderActions({ projectId }: { projectId: string }) {
|
||
return (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<Link
|
||
href={`/conversations/${projectId}/goals`}
|
||
className="flex h-11 items-center justify-center rounded-2xl bg-[#EAF7F0] text-[14px] font-semibold text-[#215B39]"
|
||
>
|
||
项目目标
|
||
</Link>
|
||
<Link
|
||
href={`/conversations/${projectId}/versions`}
|
||
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
|
||
>
|
||
版本记录
|
||
</Link>
|
||
<Link
|
||
href={`/conversations/${projectId}/forward`}
|
||
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
|
||
>
|
||
转发
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function masterIdentityPillClasses(role: MasterIdentitySummary["role"]) {
|
||
switch (role) {
|
||
case "primary":
|
||
return "border-[#BBD6FF] bg-[#EEF5FF] text-[#2457C5]";
|
||
case "backup":
|
||
return "border-[#FFD9B8] bg-[#FFF5E8] text-[#B54708]";
|
||
case "api_fallback":
|
||
return "border-[#D8DDE6] bg-[#F3F4F6] text-[#4B5563]";
|
||
default:
|
||
return "border-[#D8DDE6] bg-[#F3F4F6] text-[#4B5563]";
|
||
}
|
||
}
|
||
|
||
export function MasterIdentityPill({ identity }: { identity: MasterIdentitySummary }) {
|
||
return (
|
||
<Link
|
||
href="/me/ai-accounts"
|
||
className={clsx(
|
||
"rounded-full border px-3 py-1 text-right",
|
||
masterIdentityPillClasses(identity.role),
|
||
)}
|
||
>
|
||
<div className="text-[11px] font-semibold leading-4">{identity.roleLabel}</div>
|
||
<div className="text-[10px] leading-4 opacity-80">
|
||
{(identity.nodeLabel || identity.displayName).slice(0, 18)}
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
type PendingDispatchPlanState = {
|
||
planId: string;
|
||
summary?: string;
|
||
targets: Array<{ projectId: string; threadDisplayName: string }>;
|
||
};
|
||
|
||
export function ChatComposer({
|
||
projectId,
|
||
initialPendingDispatchPlan,
|
||
}: {
|
||
projectId: string;
|
||
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
|
||
}) {
|
||
const router = useRouter();
|
||
const [value, setValue] = useState("");
|
||
const [message, setMessage] = useState("");
|
||
const [messageTone, setMessageTone] = useState<"success" | "error">("success");
|
||
const [loading, setLoading] = useState(false);
|
||
const [localPendingDispatchPlan, setLocalPendingDispatchPlan] =
|
||
useState<PendingDispatchPlanState | null>(null);
|
||
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(null);
|
||
const pendingDispatchPlan =
|
||
localPendingDispatchPlan ??
|
||
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
|
||
? initialPendingDispatchPlan
|
||
: null);
|
||
|
||
async function confirmDispatchPlan() {
|
||
if (!pendingDispatchPlan) return;
|
||
setLoading(true);
|
||
const response = await fetch(
|
||
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/confirm`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
approvedTargetProjectIds: extractApprovedTargetProjectIds(pendingDispatchPlan),
|
||
}),
|
||
},
|
||
);
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
executions?: Array<unknown>;
|
||
message?: string;
|
||
};
|
||
setLoading(false);
|
||
if (!result.ok) {
|
||
setMessageTone("error");
|
||
setMessage(result.message ?? "确认下发失败,请重试。");
|
||
return;
|
||
}
|
||
const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length;
|
||
setLocalPendingDispatchPlan(null);
|
||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||
setMessageTone("success");
|
||
setMessage(`已确认下发到 ${executionCount} 个线程。`);
|
||
router.refresh();
|
||
}
|
||
|
||
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
|
||
setLoading(true);
|
||
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }),
|
||
});
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message?: { body: string };
|
||
dispatchPlan?: {
|
||
planId: string;
|
||
summary?: string;
|
||
targets?: Array<{ projectId: string; threadDisplayName: string }>;
|
||
} | null;
|
||
collaborationGate?: {
|
||
requiresMasterAgentApproval?: boolean;
|
||
};
|
||
messageText?: string;
|
||
};
|
||
setLoading(false);
|
||
if (result.ok) {
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
projectId,
|
||
level: "info",
|
||
category: "chat.message_sent",
|
||
message:
|
||
kind === "text" ? `已发送文本消息:${value.trim() || "空文本"}` : `已发送 ${kind} 意图消息。`,
|
||
mirrorToMaster: false,
|
||
});
|
||
setValue("");
|
||
if (result.dispatchPlan) {
|
||
setLocalPendingDispatchPlan({
|
||
planId: result.dispatchPlan.planId,
|
||
summary: result.dispatchPlan.summary,
|
||
targets: result.dispatchPlan.targets ?? [],
|
||
});
|
||
setDismissedPendingPlanId(null);
|
||
setMessage(
|
||
result.collaborationGate?.requiresMasterAgentApproval
|
||
? "消息已发送,等待你批准主 Agent 下发。"
|
||
: "消息已发送,主 Agent 已给出推荐线程。",
|
||
);
|
||
setMessageTone("success");
|
||
} else {
|
||
setLocalPendingDispatchPlan(null);
|
||
setMessage("");
|
||
}
|
||
router.refresh();
|
||
return;
|
||
}
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
projectId,
|
||
level: "error",
|
||
category: "chat.message_failed",
|
||
message: `发送 ${kind} 消息失败。`,
|
||
detail: "Boss 会话消息接口返回失败。",
|
||
mirrorToMaster: true,
|
||
});
|
||
setMessageTone("error");
|
||
setMessage("消息发送失败,请重试。");
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="mt-auto border-t border-[#E5E5EA] bg-white px-[18px] pt-3"
|
||
style={{ paddingBottom: "max(12px, env(safe-area-inset-bottom))" }}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void send("voice_intent")}
|
||
className="text-xl text-[#57606A]"
|
||
>
|
||
🎙
|
||
</button>
|
||
<input
|
||
value={value}
|
||
onChange={(event) => setValue(event.target.value)}
|
||
placeholder="向主 Agent 或项目发送消息"
|
||
className="flex-1 rounded-full bg-[#F5F5F7] px-4 py-3 text-[14px] text-[#111111] outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void send("text")}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
发送
|
||
</button>
|
||
</div>
|
||
<div className="mt-3 flex items-center gap-4 text-[12px] text-[#8C8C8C]">
|
||
<button type="button" onClick={() => void send("image_intent")}>
|
||
图片
|
||
</button>
|
||
<button type="button" onClick={() => void send("video_intent")}>
|
||
视频
|
||
</button>
|
||
<Link href={`/conversations/${projectId}/forward`}>转发</Link>
|
||
</div>
|
||
{message ? (
|
||
<div
|
||
className={clsx(
|
||
"mt-3 rounded-2xl px-4 py-3 text-[12px]",
|
||
messageTone === "success"
|
||
? "bg-[#EAF7F0] text-[#215B39]"
|
||
: "bg-[#FFF1F0] text-[#CF1322]",
|
||
)}
|
||
>
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
{pendingDispatchPlan ? (
|
||
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-4 text-[12px] leading-6 text-[#57606A]">
|
||
<div className="text-[14px] font-semibold text-[#111111]">等待你确认主 Agent 推荐</div>
|
||
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(pendingDispatchPlan)}</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void confirmDispatchPlan()}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||
>
|
||
确认下发
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => {
|
||
setLocalPendingDispatchPlan(null);
|
||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||
}}
|
||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A]"
|
||
>
|
||
稍后处理
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function GoalChecklist({
|
||
projectId,
|
||
goals,
|
||
}: {
|
||
projectId: string;
|
||
goals: GoalItem[];
|
||
}) {
|
||
const router = useRouter();
|
||
const [workingId, setWorkingId] = useState<string | null>(null);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [draft, setDraft] = useState("");
|
||
const [newGoal, setNewGoal] = useState("");
|
||
|
||
async function handleToggle(goalId: string) {
|
||
setWorkingId(goalId);
|
||
await fetch(`/api/projects/${projectId}/goals/${goalId}/toggle`, { method: "POST" });
|
||
setWorkingId(null);
|
||
router.refresh();
|
||
}
|
||
|
||
async function handleSave(goalId: string) {
|
||
await fetch(`/api/projects/${projectId}/goals/update`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ goalId, text: draft, action: "update" }),
|
||
});
|
||
setEditingId(null);
|
||
setDraft("");
|
||
router.refresh();
|
||
}
|
||
|
||
async function handleCreate() {
|
||
if (!newGoal.trim()) return;
|
||
await fetch(`/api/projects/${projectId}/goals/update`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ text: newGoal, action: "create" }),
|
||
});
|
||
setNewGoal("");
|
||
router.refresh();
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="rounded-2xl border border-dashed border-[#B7EAC7] bg-[#F5FFF8] px-4 py-4">
|
||
<div className="text-[13px] font-semibold text-[#215B39]">新增项目目标</div>
|
||
<textarea
|
||
value={newGoal}
|
||
onChange={(event) => setNewGoal(event.target.value)}
|
||
placeholder="写入新的项目目标,主 Agent 会据此调整优先级。"
|
||
className="mt-3 min-h-24 w-full rounded-xl border border-[#DFF4E8] bg-white p-3 text-[14px] leading-6 outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleCreate()}
|
||
className="mt-3 rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
添加目标
|
||
</button>
|
||
</div>
|
||
{goals.map((goal) => {
|
||
const completed = goal.state === "completed";
|
||
const editing = editingId === goal.id;
|
||
|
||
return (
|
||
<div key={goal.id} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="flex items-start gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleToggle(goal.id)}
|
||
disabled={workingId === goal.id}
|
||
className={clsx(
|
||
"mt-0.5 flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border-2",
|
||
completed
|
||
? "border-[#07C160] bg-[#07C160] text-white"
|
||
: "border-[#07C160] bg-white text-transparent",
|
||
)}
|
||
>
|
||
✓
|
||
</button>
|
||
<div className="min-w-0 flex-1">
|
||
{editing ? (
|
||
<div className="space-y-2">
|
||
<textarea
|
||
value={draft}
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
className="min-h-24 w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] p-3 text-[14px] leading-6 outline-none"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleSave(goal.id)}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
保存
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setEditingId(null);
|
||
setDraft("");
|
||
}}
|
||
className="rounded-full border border-[#E5E5EA] px-4 py-2 text-[13px] text-[#57606A]"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div
|
||
className={clsx(
|
||
"text-[15px] leading-6",
|
||
completed ? "text-[#8C8C8C] line-through" : "text-[#111111]",
|
||
)}
|
||
>
|
||
{goal.text}
|
||
</div>
|
||
<div className="mt-2 text-[12px] text-[#8C8C8C]">
|
||
{goal.note}
|
||
{goal.completedAt ? ` · ${formatClock(goal.completedAt)}` : ""}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{!editing ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setEditingId(goal.id);
|
||
setDraft(goal.text);
|
||
}}
|
||
className="text-[13px] font-semibold text-[#07C160]"
|
||
>
|
||
编辑
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AuthForm({
|
||
title,
|
||
description,
|
||
mode,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
mode: "login" | "register" | "forgot-password";
|
||
}) {
|
||
const router = useRouter();
|
||
const [account, setAccount] = useState("");
|
||
const [loginMethod, setLoginMethod] = useState<"password" | "code">("password");
|
||
const [password, setPassword] = useState("");
|
||
const [confirmPassword, setConfirmPassword] = useState("");
|
||
const [code, setCode] = useState("");
|
||
const [message, setMessage] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
async function sendCode() {
|
||
if (!account.trim()) {
|
||
setMessage("请先输入账号,再发送验证码。");
|
||
return;
|
||
}
|
||
const response = await fetch("/api/auth/send-code", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ account, purpose: mode }),
|
||
});
|
||
const result = (await response.json()) as { message: string };
|
||
setMessage(result.message);
|
||
}
|
||
|
||
async function submit() {
|
||
setSubmitting(true);
|
||
const route =
|
||
mode === "login"
|
||
? "/api/auth/login"
|
||
: mode === "register"
|
||
? "/api/auth/register"
|
||
: "/api/auth/forgot-password";
|
||
|
||
const nativeClient = await isNativeBossApp();
|
||
try {
|
||
const response = await fetch(route, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(nativeClient ? { "x-boss-native-app": "1" } : {}),
|
||
},
|
||
body: JSON.stringify({
|
||
account,
|
||
password,
|
||
confirmPassword,
|
||
code,
|
||
method: mode === "login" ? loginMethod : undefined,
|
||
}),
|
||
});
|
||
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message: string;
|
||
account?: string;
|
||
displayName?: string;
|
||
sessionExpiresAt?: string;
|
||
restoreToken?: string;
|
||
};
|
||
setMessage(result.message);
|
||
if (result.ok && mode === "login") {
|
||
if (
|
||
nativeClient &&
|
||
result.restoreToken &&
|
||
result.account &&
|
||
result.displayName &&
|
||
result.sessionExpiresAt
|
||
) {
|
||
try {
|
||
await persistNativeSessionSnapshot({
|
||
restoreToken: result.restoreToken,
|
||
account: result.account,
|
||
displayName: result.displayName,
|
||
expiresAt: result.sessionExpiresAt,
|
||
lastSyncedAt: new Date().toISOString(),
|
||
});
|
||
} catch (error) {
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
level: "warn",
|
||
category: "auth.native_restore_snapshot_failed",
|
||
message: "登录成功,但原生 restore token 快照写入失败。",
|
||
detail: error instanceof Error ? error.message : String(error),
|
||
mirrorToMaster: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
await waitForLoginSessionReady(nativeClient);
|
||
navigateToConversations(router);
|
||
return;
|
||
}
|
||
if (result.ok && mode === "register") {
|
||
router.push("/auth/login");
|
||
}
|
||
} catch (error) {
|
||
setMessage("登录链路发生异常,请重试。");
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
level: "error",
|
||
category: "auth.submit_failed",
|
||
message: "登录或注册提交失败。",
|
||
detail: error instanceof Error ? error.message : String(error),
|
||
mirrorToMaster: true,
|
||
});
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4 px-[18px] pb-8">
|
||
<div className="space-y-2 px-0 py-2">
|
||
<div className="flex h-16 w-16 items-center justify-center rounded-[18px] bg-[#07C160] text-[30px] font-semibold text-white">
|
||
C
|
||
</div>
|
||
<h1 className="pt-2 text-[34px] font-semibold tracking-[-0.02em] text-[#111111]">
|
||
{title}
|
||
</h1>
|
||
<p className="text-[14px] leading-6 text-[#8C8C8C]">{description}</p>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-2xl border border-[#E5E5EA] bg-white">
|
||
{mode === "login" ? (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-2 bg-[#F7F8FA] px-4 py-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setLoginMethod("password");
|
||
setMessage("");
|
||
}}
|
||
className={clsx(
|
||
"rounded-xl px-3 py-2 text-[13px] font-semibold",
|
||
loginMethod === "password" ? "bg-[#07C160] text-white" : "bg-white text-[#57606A]",
|
||
)}
|
||
>
|
||
账号密码登录
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setLoginMethod("code");
|
||
setMessage("");
|
||
}}
|
||
className={clsx(
|
||
"rounded-xl px-3 py-2 text-[13px] font-semibold",
|
||
loginMethod === "code" ? "bg-[#07C160] text-white" : "bg-white text-[#57606A]",
|
||
)}
|
||
>
|
||
验证码登录
|
||
</button>
|
||
</div>
|
||
<div className="h-px w-full bg-[#E5E5EA]" />
|
||
</>
|
||
) : null}
|
||
<AuthField label="账号" value={account} onChange={setAccount} placeholder="输入手机号 / 邮箱" />
|
||
{mode === "login" && loginMethod === "code" ? (
|
||
<>
|
||
<AuthCodeField label="登录验证码" value={code} onChange={setCode} onSend={sendCode} />
|
||
<div className="border-t border-[#E5E5EA] px-4 py-3 text-[12px] leading-5 text-[#57606A]">
|
||
当前固定验证码模式下,可直接输入 <span className="font-semibold text-[#111111]">000000</span>{" "}
|
||
登录;如需确认账号状态,也可以先点“发送验证码”。
|
||
</div>
|
||
</>
|
||
) : null}
|
||
{mode !== "login" ? (
|
||
<AuthCodeField
|
||
label={mode === "register" ? "注册验证码" : "重置验证码"}
|
||
value={code}
|
||
onChange={setCode}
|
||
onSend={sendCode}
|
||
/>
|
||
) : null}
|
||
{(mode !== "login" || loginMethod === "password") ? (
|
||
<AuthField
|
||
label={mode === "forgot-password" ? "新密码" : mode === "register" ? "登录密码" : "密码"}
|
||
value={password}
|
||
onChange={setPassword}
|
||
placeholder={mode === "register" ? "设置 8-20 位密码" : "输入密码"}
|
||
type="password"
|
||
/>
|
||
) : null}
|
||
{(mode === "register" || mode === "forgot-password") && (
|
||
<AuthField
|
||
label={mode === "register" ? "确认密码" : "确认新密码"}
|
||
value={confirmPassword}
|
||
onChange={setConfirmPassword}
|
||
placeholder="再次输入密码"
|
||
type="password"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => void submit()}
|
||
disabled={submitting}
|
||
className="flex h-[52px] w-full items-center justify-center rounded-[14px] bg-[#07C160] text-[17px] font-semibold text-white"
|
||
>
|
||
{submitting ? "处理中..." : mode === "login" ? "登录" : mode === "register" ? "注册并继续" : "确认重置"}
|
||
</button>
|
||
|
||
{message ? (
|
||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[13px] leading-6 text-[#215B39]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex items-center justify-between text-[14px]">
|
||
{mode === "login" ? (
|
||
<>
|
||
<Link href="/auth/register" className="text-[#07C160]">
|
||
注册账号
|
||
</Link>
|
||
<Link href="/auth/forgot-password" className="text-[#57606A]">
|
||
忘记密码
|
||
</Link>
|
||
</>
|
||
) : mode === "register" ? (
|
||
<Link href="/auth/login" className="mx-auto text-[#07C160]">
|
||
已有账号,去登录
|
||
</Link>
|
||
) : (
|
||
<Link href="/auth/login" className="mx-auto text-[#57606A]">
|
||
密码重置成功后,回到登录页继续验证。
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AuthField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
type = "text",
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder: string;
|
||
type?: "text" | "password";
|
||
}) {
|
||
return (
|
||
<>
|
||
<div className="space-y-1 px-4 py-3">
|
||
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
|
||
<input
|
||
type={type}
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full border-0 bg-transparent p-0 text-[16px] text-[#111111] outline-none"
|
||
/>
|
||
</div>
|
||
<div className="h-px w-full bg-[#E5E5EA]" />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AuthCodeField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
onSend,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
onSend: () => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
<div className="flex items-center gap-3 px-4 py-3">
|
||
<div className="min-w-0 flex-1 space-y-1">
|
||
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
|
||
<input
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder="输入 6 位验证码"
|
||
inputMode="numeric"
|
||
className="w-full border-0 bg-transparent p-0 text-[16px] text-[#111111] outline-none"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onSend}
|
||
className="rounded-xl border border-[#07C160] bg-[#F5FFF8] px-3 py-2 text-[13px] font-semibold text-[#07C160]"
|
||
>
|
||
发送验证码
|
||
</button>
|
||
</div>
|
||
<div className="h-px w-full bg-[#E5E5EA]" />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
return (
|
||
<label className="block space-y-1">
|
||
<span className="text-[12px] text-[#8C8C8C]">{label}</span>
|
||
<input
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] px-3 py-3 text-[14px] outline-none"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function DeviceEnrollmentBuilder() {
|
||
const [name, setName] = useState("Mac Mini");
|
||
const [avatar, setAvatar] = useState("M");
|
||
const [account, setAccount] = useState("17600003315");
|
||
const [projects, setProjects] = useState("Boss 移动控制台");
|
||
const [endpoint, setEndpoint] = useState("mac://new-device.local");
|
||
const [note, setNote] = useState("新设备待绑定");
|
||
const [result, setResult] = useState<{
|
||
enrollment?: DeviceEnrollment;
|
||
device?: Device;
|
||
message?: string;
|
||
}>({});
|
||
|
||
async function submit() {
|
||
const response = await fetch("/api/v1/devices/enrollments", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name,
|
||
avatar,
|
||
account,
|
||
endpoint,
|
||
note,
|
||
projects: projects
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
}),
|
||
});
|
||
const data = (await response.json()) as {
|
||
ok: boolean;
|
||
enrollment?: DeviceEnrollment;
|
||
device?: Device;
|
||
message?: string;
|
||
};
|
||
setResult(data);
|
||
}
|
||
|
||
const configSnippet = result.enrollment
|
||
? JSON.stringify(
|
||
{
|
||
bindHost: "127.0.0.1",
|
||
port: 4317,
|
||
heartbeatIntervalMs: 60000,
|
||
controlPlaneUrl: "http://127.0.0.1:3000",
|
||
deviceId: result.device?.id,
|
||
pairingCode: result.enrollment.pairingCode,
|
||
token: result.enrollment.token,
|
||
name,
|
||
avatar,
|
||
account,
|
||
status: "online",
|
||
quota5h: 100,
|
||
quota7d: 100,
|
||
projects: projects
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
endpoint,
|
||
},
|
||
null,
|
||
2,
|
||
)
|
||
: "";
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="text-[16px] font-semibold text-[#111111]">生成设备绑定草稿</div>
|
||
<Field label="设备名称" value={name} onChange={setName} />
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="头像缩写" value={avatar} onChange={setAvatar} />
|
||
<Field label="账号" value={account} onChange={setAccount} />
|
||
</div>
|
||
<Field label="项目列表(逗号分隔)" value={projects} onChange={setProjects} />
|
||
<Field label="Endpoint" value={endpoint} onChange={setEndpoint} />
|
||
<Field label="备注" value={note} onChange={setNote} />
|
||
<button
|
||
type="button"
|
||
onClick={() => void submit()}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
生成 pairing code / token
|
||
</button>
|
||
{result.message ? (
|
||
<div className="rounded-2xl bg-[#FFF1F0] px-4 py-3 text-[12px] text-[#CF1322]">
|
||
{result.message}
|
||
</div>
|
||
) : null}
|
||
{result.enrollment ? (
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
Pairing code: <span className="font-semibold text-[#111111]">{result.enrollment.pairingCode}</span>
|
||
<br />
|
||
Token: <span className="font-semibold text-[#111111]">{result.enrollment.token}</span>
|
||
<br />
|
||
设备 ID: {result.device?.id}
|
||
<br />
|
||
过期时间:{formatClock(result.enrollment.expiresAt)}
|
||
</div>
|
||
) : null}
|
||
{configSnippet ? (
|
||
<pre className="overflow-x-auto rounded-2xl bg-[#111111] p-4 text-[11px] leading-6 text-[#E8E8E8]">
|
||
{configSnippet}
|
||
</pre>
|
||
) : null}
|
||
{result.device?.id ? (
|
||
<DeviceImportDraftManager deviceId={result.device.id} deviceName={result.device.name} />
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OtaActionButton({ canApply = true }: { canApply?: boolean }) {
|
||
const router = useRouter();
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function run() {
|
||
if (!canApply) {
|
||
setMessage("当前账号没有 OTA 执行权限。");
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
level: "warn",
|
||
category: "ota.apply_blocked",
|
||
message: "当前账号没有 OTA 执行权限。",
|
||
mirrorToMaster: true,
|
||
});
|
||
return;
|
||
}
|
||
const response = await fetch("/api/v1/user/ota", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: "apply" }),
|
||
});
|
||
const result = (await response.json()) as { ok: boolean; version?: string; message?: string };
|
||
setMessage(result.ok ? `已升级到 ${result.version}` : result.message ?? "OTA 失败");
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
level: result.ok ? "info" : "error",
|
||
category: result.ok ? "ota.apply_succeeded" : "ota.apply_failed",
|
||
message: result.ok ? `已执行 OTA 升级到 ${result.version}` : result.message ?? "OTA 失败",
|
||
mirrorToMaster: true,
|
||
});
|
||
if (result.ok) router.refresh();
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => void run()}
|
||
disabled={!canApply}
|
||
className={clsx(
|
||
"rounded-full px-4 py-2 text-[13px] font-semibold",
|
||
canApply ? "bg-[#07C160] text-white" : "bg-[#E5E5EA] text-[#8C8C8C]",
|
||
)}
|
||
>
|
||
立即 OTA
|
||
</button>
|
||
{message ? (
|
||
<div className="mt-3 rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] text-[#215B39]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function OtaCenterCard({
|
||
currentVersion,
|
||
availableRelease,
|
||
logs,
|
||
boundCodexNodeLabel,
|
||
roleLabel,
|
||
canApply,
|
||
compact = false,
|
||
}: {
|
||
currentVersion: string;
|
||
availableRelease?: OtaUpdate | null;
|
||
logs: OtaUpdateLog[];
|
||
boundCodexNodeLabel?: string;
|
||
roleLabel: string;
|
||
canApply: boolean;
|
||
compact?: boolean;
|
||
}) {
|
||
const router = useRouter();
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function checkUpdate() {
|
||
const response = await fetch("/api/v1/user/ota", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: "check" }),
|
||
});
|
||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||
setMessage(result.message ?? (result.ok ? "检查完成。" : "检查失败"));
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
level: result.ok ? "info" : "error",
|
||
category: result.ok ? "ota.check_succeeded" : "ota.check_failed",
|
||
message: result.message ?? (result.ok ? "检查完成。" : "检查失败"),
|
||
mirrorToMaster: !result.ok,
|
||
});
|
||
if (result.ok) router.refresh();
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-[16px] font-semibold text-[#111111]">关于 / OTA</div>
|
||
<div className="mt-1 text-[13px] leading-6 text-[#57606A]">
|
||
当前版本 {currentVersion}
|
||
<br />
|
||
管理员角色:{roleLabel}
|
||
<br />
|
||
绑定节点:{boundCodexNodeLabel ?? "未绑定"}
|
||
</div>
|
||
</div>
|
||
{availableRelease ? (
|
||
<span className="rounded-full bg-[#FFF1F0] px-3 py-1 text-[12px] font-semibold text-[#CF1322]">
|
||
OTA
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-2xl bg-[#F7F8FA] px-4 py-4">
|
||
<div className="text-[14px] font-semibold text-[#111111]">
|
||
{availableRelease ? `可用版本 ${availableRelease.version}` : "当前没有待升级版本"}
|
||
</div>
|
||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||
{availableRelease ? (
|
||
<>
|
||
来源版本:{availableRelease.currentVersion}
|
||
<br />
|
||
发布范围:{availableRelease.targetScope}
|
||
<br />
|
||
{(availableRelease.summary ?? []).join(";")}
|
||
{availableRelease.packageFileName ? (
|
||
<>
|
||
<br />
|
||
安装包:{availableRelease.packageFileName}
|
||
{availableRelease.packageSizeBytes ? ` · ${formatBytes(availableRelease.packageSizeBytes)}` : ""}
|
||
</>
|
||
) : null}
|
||
{availableRelease.assetUpdatedAt ? (
|
||
<>
|
||
<br />
|
||
包更新时间:{formatClock(availableRelease.assetUpdatedAt)}
|
||
</>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
"点击“检查更新”可重新拉取 OTA 状态。"
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void checkUpdate()}
|
||
className="rounded-full border border-[#07C160] bg-[#F5FFF8] px-4 py-2 text-[13px] font-semibold text-[#07C160]"
|
||
>
|
||
检查更新
|
||
</button>
|
||
{availableRelease?.downloadUrl ? (
|
||
<a
|
||
href={availableRelease.downloadUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="rounded-full border border-[#111111] bg-white px-4 py-2 text-[13px] font-semibold text-[#111111]"
|
||
>
|
||
下载 APK
|
||
</a>
|
||
) : null}
|
||
{availableRelease ? <OtaActionButton canApply={canApply} /> : null}
|
||
</div>
|
||
|
||
{message ? (
|
||
<div className="mt-3 rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
|
||
{availableRelease?.packageSha256 ? (
|
||
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
SHA256:{availableRelease.packageSha256}
|
||
</div>
|
||
) : null}
|
||
|
||
{!compact ? (
|
||
<div className="mt-4 rounded-2xl bg-[#F7F8FA] px-4 py-4">
|
||
<div className="text-[14px] font-semibold text-[#111111]">OTA 记录</div>
|
||
<div className="mt-2 space-y-3 text-[12px] leading-6 text-[#57606A]">
|
||
{logs.length ? (
|
||
logs.slice(0, 4).map((log) => (
|
||
<div key={log.logId}>
|
||
<div className="font-semibold text-[#111111]">
|
||
{log.version} · {log.status}
|
||
</div>
|
||
<div>
|
||
{formatClock(log.triggeredAt)} · {log.note}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div>当前还没有 OTA 记录。</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SettingsForm({ settings }: { settings: UserSettings }) {
|
||
const router = useRouter();
|
||
const [draft, setDraft] = useState(settings);
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function save() {
|
||
const response = await fetch("/api/v1/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(draft),
|
||
});
|
||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||
setMessage(result.ok ? "设置已保存。" : result.message ?? "保存失败");
|
||
if (result.ok) router.refresh();
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<SwitchRow
|
||
label="启用实时刷新提示"
|
||
value={draft.liveUpdates}
|
||
onChange={(value) => setDraft((current) => ({ ...current, liveUpdates: value }))}
|
||
/>
|
||
<SwitchRow
|
||
label="显示风险徽标"
|
||
value={draft.showRiskBadges}
|
||
onChange={(value) => setDraft((current) => ({ ...current, showRiskBadges: value }))}
|
||
/>
|
||
<SwitchRow
|
||
label="危险操作前二次确认"
|
||
value={draft.confirmDangerousActions}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, confirmDangerousActions: value }))
|
||
}
|
||
/>
|
||
<div className="space-y-1">
|
||
<div className="text-[12px] text-[#8C8C8C]">默认首页</div>
|
||
<div className="flex gap-2">
|
||
{(["conversations", "devices", "me"] as const).map((item) => (
|
||
<button
|
||
key={item}
|
||
type="button"
|
||
onClick={() => setDraft((current) => ({ ...current, preferredEntryPoint: item }))}
|
||
className={clsx(
|
||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||
draft.preferredEntryPoint === item
|
||
? "bg-[#07C160] text-white"
|
||
: "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void save()}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
保存设置
|
||
</button>
|
||
{message ? (
|
||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] text-[#215B39]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SwitchRow({
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: boolean;
|
||
onChange: (value: boolean) => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(!value)}
|
||
className="flex w-full items-center justify-between rounded-2xl bg-[#F7F8FA] px-4 py-3"
|
||
>
|
||
<span className="text-[14px] text-[#111111]">{label}</span>
|
||
<span
|
||
className={clsx(
|
||
"rounded-full px-3 py-1 text-[12px] font-semibold",
|
||
value ? "bg-[#07C160] text-white" : "bg-white text-[#57606A]",
|
||
)}
|
||
>
|
||
{value ? "开" : "关"}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function RepairTicketActions({
|
||
ticket,
|
||
verification,
|
||
}: {
|
||
ticket: OpsRepairTicket;
|
||
verification?: OpsRepairVerification;
|
||
}) {
|
||
const router = useRouter();
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function run(action: "approve" | "verify") {
|
||
const response = await fetch(`/api/v1/ops/repair-tickets/${ticket.ticketId}/${action}`, {
|
||
method: "POST",
|
||
});
|
||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||
setMessage(result.ok ? "工单状态已更新。" : result.message ?? "操作失败");
|
||
if (result.ok) router.refresh();
|
||
}
|
||
|
||
return (
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
{ticket.approvalStatus !== "approved" ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void run("approve")}
|
||
className="rounded-full bg-[#07C160] px-3 py-2 text-[12px] font-semibold text-white"
|
||
>
|
||
主 Agent 批准
|
||
</button>
|
||
) : null}
|
||
{ticket.executionStatus !== "verified" ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void run("verify")}
|
||
className="rounded-full border border-[#07C160] px-3 py-2 text-[12px] font-semibold text-[#07C160]"
|
||
>
|
||
运维审计复验
|
||
</button>
|
||
) : null}
|
||
{verification ? (
|
||
<span className="rounded-full bg-[#F5F5F7] px-3 py-2 text-[12px] text-[#57606A]">
|
||
复验:{verification.status}
|
||
</span>
|
||
) : null}
|
||
{message ? (
|
||
<span className="text-[12px] text-[#57606A]">{message}</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ForwardComposer({
|
||
sourceProjectId,
|
||
targets,
|
||
}: {
|
||
sourceProjectId: string;
|
||
targets: Array<{ id: string; name: string }>;
|
||
}) {
|
||
const router = useRouter();
|
||
const [targetProjectId, setTargetProjectId] = useState(targets[0]?.id ?? "");
|
||
const [note, setNote] = useState("");
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function submit() {
|
||
const response = await fetch(`/api/v1/projects/${sourceProjectId}/forwards`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ targetProjectId, note }),
|
||
});
|
||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||
if (result.ok) {
|
||
router.push(`/conversations/${targetProjectId}`);
|
||
router.refresh();
|
||
return;
|
||
}
|
||
setMessage(result.message ?? "转发失败");
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="text-[16px] font-semibold text-[#111111]">转发到项目</div>
|
||
<label className="block space-y-1">
|
||
<span className="text-[12px] text-[#8C8C8C]">目标项目</span>
|
||
<select
|
||
value={targetProjectId}
|
||
onChange={(event) => setTargetProjectId(event.target.value)}
|
||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] px-3 py-3 text-[14px] outline-none"
|
||
>
|
||
{targets.map((target) => (
|
||
<option key={target.id} value={target.id}>
|
||
{target.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="block space-y-1">
|
||
<span className="text-[12px] text-[#8C8C8C]">转发说明</span>
|
||
<textarea
|
||
value={note}
|
||
onChange={(event) => setNote(event.target.value)}
|
||
className="min-h-28 w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] p-3 text-[14px] leading-6 outline-none"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => void submit()}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
|
||
>
|
||
执行转发
|
||
</button>
|
||
{message ? (
|
||
<div className="rounded-2xl bg-[#FFF1F0] px-4 py-3 text-[12px] text-[#CF1322]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|