3013 lines
108 KiB
TypeScript
3013 lines
108 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 { getMasterAgentChatMenuItems } from "@/lib/master-agent-chat-menu";
|
||
import {
|
||
extractApprovedTargetProjectIds,
|
||
summarizeDispatchPlan,
|
||
summarizeDispatchPlanCompact,
|
||
summarizeDispatchPlanLightTitle,
|
||
} from "@/lib/dispatch-plan-ui";
|
||
import type {
|
||
ThreadConversationExecutionConflict,
|
||
ThreadConversationExecutionConflictAction,
|
||
} from "@/lib/thread-execution-conflict";
|
||
import { parseChatMarkdown, type ChatMarkdownBlock } from "@/lib/chat-markdown";
|
||
import {
|
||
describeThreadConversationExecutionConflict,
|
||
labelForProjectConflictAllowPolicy,
|
||
labelForProjectConflictState,
|
||
labelForThreadConversationExecutionConflictDecision,
|
||
summarizeThreadConversationExecutionDecisionResult,
|
||
} from "@/lib/thread-execution-conflict-ui";
|
||
import type {
|
||
Device,
|
||
DeviceEnrollment,
|
||
GoalItem,
|
||
MasterIdentitySummary,
|
||
Message,
|
||
OtaUpdate,
|
||
OtaUpdateLog,
|
||
OpsRepairTicket,
|
||
OpsRepairVerification,
|
||
ProjectOrchestrationBackendState,
|
||
OrchestrationBackendId,
|
||
ThreadContextSnapshot,
|
||
UserProfile,
|
||
UserSettings,
|
||
} from "@/lib/boss-data";
|
||
import type { ConversationItem, DeviceWorkspaceView } 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";
|
||
}
|
||
|
||
function arrayLength(value: unknown) {
|
||
return Array.isArray(value) ? value.length : 0;
|
||
}
|
||
|
||
function textFromMetadata(value: unknown) {
|
||
return typeof value === "string" && value.trim() ? value.trim() : "未发现";
|
||
}
|
||
|
||
function objectFromMetadata(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||
}
|
||
|
||
function numberFromMetadata(value: unknown) {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? Math.max(0, Math.round(numeric)) : 0;
|
||
}
|
||
|
||
function minuteTimestampFromMetadata(value: unknown) {
|
||
const text = typeof value === "string" ? value.trim() : "";
|
||
if (!text) return "未发现";
|
||
const date = new Date(text);
|
||
if (Number.isNaN(date.getTime())) return text;
|
||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||
timeZone: "Asia/Shanghai",
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
}).formatToParts(date);
|
||
const valueOf = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||
return `${valueOf("year")}-${valueOf("month")}-${valueOf("day")} ${valueOf("hour")}:${valueOf("minute")}`;
|
||
}
|
||
|
||
export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView) {
|
||
const selectedDevice = workspace.selectedDevice;
|
||
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
|
||
const codexAppServerMetadata = selectedDevice?.capabilities?.codexAppServer?.metadata ?? {};
|
||
const codexModelsCount = arrayLength(codexAppServerMetadata.models);
|
||
const codexSkillCount = arrayLength(codexAppServerMetadata.skills);
|
||
const codexPluginCount = arrayLength(codexAppServerMetadata.plugins);
|
||
const codexAppCount = arrayLength(codexAppServerMetadata.apps);
|
||
const codexExperimentalFeatureCount = arrayLength(codexAppServerMetadata.experimentalFeatures);
|
||
const codexCollaborationModeCount = arrayLength(codexAppServerMetadata.collaborationModes);
|
||
const codexMcpServerCount = arrayLength(codexAppServerMetadata.mcpServers);
|
||
const codexPermissionProfileCount = arrayLength(codexAppServerMetadata.permissionProfiles);
|
||
const codexAccountSummary = objectFromMetadata(codexAppServerMetadata.accountSummary);
|
||
const codexRateLimitSummary = objectFromMetadata(codexAppServerMetadata.rateLimitSummary);
|
||
const codexAppConfigSummary = objectFromMetadata(codexAppServerMetadata.appConfigSummary);
|
||
const codexConfigRequirements = objectFromMetadata(codexAppServerMetadata.configRequirements);
|
||
const codexExternalAgentMigration = objectFromMetadata(codexAppServerMetadata.externalAgentMigration);
|
||
const codexSkillExtraRootsSummary = objectFromMetadata(codexAppServerMetadata.skillExtraRootsSummary);
|
||
const codexHookSummary = objectFromMetadata(codexAppServerMetadata.hookSummary);
|
||
const codexThreadSummary = objectFromMetadata(codexAppServerMetadata.threadSummary);
|
||
const codexThreadTurnSummary = objectFromMetadata(codexAppServerMetadata.threadTurnSummary);
|
||
const codexThreadActionSummary = objectFromMetadata(codexAppServerMetadata.threadActionSummary);
|
||
const codexPluginGovernanceSummary = objectFromMetadata(codexAppServerMetadata.pluginGovernanceSummary);
|
||
const codexAccountGovernanceSummary = objectFromMetadata(codexAppServerMetadata.accountGovernanceSummary);
|
||
const codexConfigGovernanceSummary = objectFromMetadata(codexAppServerMetadata.configGovernanceSummary);
|
||
const codexFileSystemGovernanceSummary = objectFromMetadata(codexAppServerMetadata.fileSystemGovernanceSummary);
|
||
const codexCommandSessionSummary = objectFromMetadata(codexAppServerMetadata.commandSessionSummary);
|
||
const codexExternalAgentGovernanceSummary = objectFromMetadata(
|
||
codexAppServerMetadata.externalAgentGovernanceSummary,
|
||
);
|
||
const codexMarketplaceGovernanceSummary = objectFromMetadata(codexAppServerMetadata.marketplaceGovernanceSummary);
|
||
const codexExperimentalFeatureGovernanceSummary = objectFromMetadata(
|
||
codexAppServerMetadata.experimentalFeatureGovernanceSummary,
|
||
);
|
||
const codexReviewGovernanceSummary = objectFromMetadata(codexAppServerMetadata.reviewGovernanceSummary);
|
||
const codexWindowsSandboxGovernanceSummary = objectFromMetadata(
|
||
codexAppServerMetadata.windowsSandboxGovernanceSummary,
|
||
);
|
||
const codexFuzzyFileSearchSummary = objectFromMetadata(codexAppServerMetadata.fuzzyFileSearchSummary);
|
||
|
||
return {
|
||
capabilities: {
|
||
title: "执行能力",
|
||
items: {
|
||
gui: `GUI:${selectedDevice?.capabilities?.gui?.connected ? "已连接" : "未连接"}`,
|
||
cli: `CLI:${selectedDevice?.capabilities?.cli?.connected ? "已连接" : "未连接"}`,
|
||
browserAutomation: `浏览器自动化:${
|
||
selectedDevice?.capabilities?.browserAutomation?.connected ? "已连接" : "未连接"
|
||
}`,
|
||
computerUse: `桌面控制:${
|
||
selectedDevice?.capabilities?.computerUse?.connected ? "已连接" : "未连接"
|
||
}`,
|
||
codexAppServer: `Codex App Server:${
|
||
selectedDevice?.capabilities?.codexAppServer?.connected ? "已连接" : "未连接"
|
||
}`,
|
||
codexModels:
|
||
codexModelsCount > 0
|
||
? `模型:${codexModelsCount} 个 · 默认 ${textFromMetadata(
|
||
codexAppServerMetadata.defaultModelId,
|
||
)} · 快速 ${textFromMetadata(codexAppServerMetadata.fastModelId)} · 深度 ${textFromMetadata(
|
||
codexAppServerMetadata.deepModelId,
|
||
)}`
|
||
: "模型:未发现",
|
||
codexExtensions: `扩展:Skill ${codexSkillCount} 个 · Plugin ${codexPluginCount} 个 · App ${codexAppCount} 个`,
|
||
codexGovernance: `治理:实验特性 ${codexExperimentalFeatureCount} 个 · 协作模式 ${codexCollaborationModeCount} 个 · MCP ${codexMcpServerCount} 个 · 权限 ${codexPermissionProfileCount} 个`,
|
||
codexAccount: `账号:${textFromMetadata(codexAccountSummary.authMode)} · 套餐 ${textFromMetadata(
|
||
codexAccountSummary.planType,
|
||
)} · 额度 ${numberFromMetadata(codexRateLimitSummary.maxUsedPercent)}%`,
|
||
codexConfig: `配置:App ${numberFromMetadata(codexAppConfigSummary.appCount)} 个 · 已启用 ${numberFromMetadata(
|
||
codexAppConfigSummary.enabledAppCount,
|
||
)} 个 · 托管要求 ${numberFromMetadata(codexConfigRequirements.requirementCount)} 个 · 外部迁移 ${numberFromMetadata(
|
||
codexExternalAgentMigration.itemCount,
|
||
)} 项`,
|
||
codexSkillRoots: `共享 Skill 根:${numberFromMetadata(codexSkillExtraRootsSummary.rootCount)} 个 · ${
|
||
textFromMetadata(codexSkillExtraRootsSummary.status) === "applied"
|
||
? "已下发"
|
||
: textFromMetadata(codexSkillExtraRootsSummary.status) === "failed"
|
||
? "下发失败"
|
||
: "未配置"
|
||
}`,
|
||
codexHooks: `Hook:${numberFromMetadata(codexHookSummary.hookCount)} 个 · 启用 ${numberFromMetadata(
|
||
codexHookSummary.enabledHookCount,
|
||
)} 个 · 警告 ${numberFromMetadata(codexHookSummary.warningCount)} 个`,
|
||
codexThreads: `线程:${numberFromMetadata(codexThreadSummary.threadCount)} 个 · 已加载 ${numberFromMetadata(
|
||
codexThreadSummary.loadedThreadCount,
|
||
)} 个 · 活跃 ${numberFromMetadata(codexThreadSummary.activeThreadCount)} 个 · 最新 ${minuteTimestampFromMetadata(
|
||
codexThreadSummary.latestUpdatedAt,
|
||
)}`,
|
||
codexTurns: `轮次:${numberFromMetadata(codexThreadTurnSummary.totalTurnCount)} 个 · 运行中 ${numberFromMetadata(
|
||
codexThreadTurnSummary.runningTurnCount,
|
||
)} 个 · 完成 ${numberFromMetadata(codexThreadTurnSummary.completedTurnCount)} 个 · 最新 ${minuteTimestampFromMetadata(
|
||
codexThreadTurnSummary.latestUpdatedAt,
|
||
)}`,
|
||
codexThreadActions: `线程操作:${numberFromMetadata(
|
||
codexThreadActionSummary.actionCount,
|
||
)} 项 · 生命周期 ${numberFromMetadata(codexThreadActionSummary.lifecycleActionCount)} 项 · 活跃干预 ${numberFromMetadata(
|
||
codexThreadActionSummary.liveTurnActionCount,
|
||
)} 项 · ${codexThreadActionSummary.shellActionAvailable === true ? "Shell 可用" : "Shell 不可用"}`,
|
||
codexPluginGovernance: `插件治理:${numberFromMetadata(
|
||
codexPluginGovernanceSummary.actionCount,
|
||
)} 项 · 安装/卸载 ${numberFromMetadata(codexPluginGovernanceSummary.lifecycleActionCount)} 项 · 共享 ${numberFromMetadata(
|
||
codexPluginGovernanceSummary.shareActionCount,
|
||
)} 项 · ${codexPluginGovernanceSummary.skillReadAvailable === true ? "Skill 读取可用" : "Skill 读取不可用"}`,
|
||
codexAccountGovernance: `账号治理:${numberFromMetadata(
|
||
codexAccountGovernanceSummary.actionCount,
|
||
)} 项 · 登录 ${numberFromMetadata(codexAccountGovernanceSummary.loginActionCount)} 项 · ${
|
||
codexAccountGovernanceSummary.tokenRefreshAvailable === true ? "令牌刷新可用" : "令牌刷新不可用"
|
||
} · ${codexAccountGovernanceSummary.billingNudgeAvailable === true ? "额度提醒可用" : "额度提醒不可用"}`,
|
||
codexConfigGovernance: `配置治理:${numberFromMetadata(
|
||
codexConfigGovernanceSummary.actionCount,
|
||
)} 项 · 写入 ${numberFromMetadata(codexConfigGovernanceSummary.writeActionCount)} 项 · 重载 ${numberFromMetadata(
|
||
codexConfigGovernanceSummary.reloadActionCount,
|
||
)} 项 · ${codexConfigGovernanceSummary.readActionAvailable === true ? "读取可用" : "读取不可用"}`,
|
||
codexFileSystemGovernance: `文件治理:${numberFromMetadata(
|
||
codexFileSystemGovernanceSummary.actionCount,
|
||
)} 项 · 读 ${numberFromMetadata(codexFileSystemGovernanceSummary.readActionCount)} 项 · 写 ${numberFromMetadata(
|
||
codexFileSystemGovernanceSummary.writeActionCount,
|
||
)} 项 · 监听 ${numberFromMetadata(codexFileSystemGovernanceSummary.watchActionCount)} 项`,
|
||
codexCommandSession: `命令会话:${numberFromMetadata(
|
||
codexCommandSessionSummary.actionCount,
|
||
)} 项 · 控制 ${numberFromMetadata(codexCommandSessionSummary.controlActionCount)} 项 · ${
|
||
codexCommandSessionSummary.streamAvailable === true ? "输出流可用" : "输出流不可用"
|
||
} · ${codexCommandSessionSummary.terminationAvailable === true ? "可终止" : "不可终止"}`,
|
||
codexExternalAgentGovernance: `迁移治理:${numberFromMetadata(
|
||
codexExternalAgentGovernanceSummary.actionCount,
|
||
)} 项 · 导入 ${numberFromMetadata(codexExternalAgentGovernanceSummary.importActionCount)} 项 · ${
|
||
codexExternalAgentGovernanceSummary.detectActionAvailable === true ? "检测可用" : "检测不可用"
|
||
}`,
|
||
codexMarketplaceGovernance: `市场治理:${numberFromMetadata(
|
||
codexMarketplaceGovernanceSummary.actionCount,
|
||
)} 项 · 写入 ${numberFromMetadata(codexMarketplaceGovernanceSummary.writeActionCount)} 项 · ${
|
||
codexMarketplaceGovernanceSummary.upgradeAvailable === true ? "升级可用" : "升级不可用"
|
||
}`,
|
||
codexExperimentalFeatureGovernance: `实验特性治理:${numberFromMetadata(
|
||
codexExperimentalFeatureGovernanceSummary.actionCount,
|
||
)} 项 · 写入 ${numberFromMetadata(codexExperimentalFeatureGovernanceSummary.writeActionCount)} 项 · ${
|
||
codexExperimentalFeatureGovernanceSummary.listAvailable === true ? "列表可用" : "列表不可用"
|
||
}`,
|
||
codexReviewGovernance: `审查治理:${numberFromMetadata(
|
||
codexReviewGovernanceSummary.actionCount,
|
||
)} 项 · ${codexReviewGovernanceSummary.reviewStartAvailable === true ? "审查可启动" : "审查不可启动"}`,
|
||
codexWindowsSandboxGovernance: `Windows 沙箱:${numberFromMetadata(
|
||
codexWindowsSandboxGovernanceSummary.actionCount,
|
||
)} 项 · 设置 ${numberFromMetadata(codexWindowsSandboxGovernanceSummary.setupActionCount)} 项 · ${
|
||
codexWindowsSandboxGovernanceSummary.readinessAvailable === true ? "准备检查可用" : "准备检查不可用"
|
||
}`,
|
||
codexFuzzyFileSearch: `文件搜索事件:${numberFromMetadata(
|
||
codexFuzzyFileSearchSummary.eventCount,
|
||
)} 项 · ${codexFuzzyFileSearchSummary.completedEventAvailable === true ? "完成事件可用" : "完成事件不可用"}`,
|
||
preferredExecutionMode: `默认执行模式:${
|
||
selectedDevice?.preferredExecutionMode === "gui"
|
||
? "GUI"
|
||
: selectedDevice?.preferredExecutionMode === "cli"
|
||
? "CLI"
|
||
: "未知"
|
||
}`,
|
||
},
|
||
},
|
||
conflicts: {
|
||
title: "异常项目 / 文件夹冲突",
|
||
headerHint: primaryPolicy ? "已接入,可直接调整" : "当前没有异常项目",
|
||
scopeLabel: "仅作用于当前异常项目 / 文件夹",
|
||
actions: ["禁止", "允许本次", "永久放行"],
|
||
items: {
|
||
device: `设备:${selectedDevice?.name ?? selectedDevice?.id ?? "未知设备"}`,
|
||
folderKey: `文件夹:${primaryPolicy?.folderKey ?? "暂无"}`,
|
||
projectId: `项目:${primaryPolicy?.projectId ?? "暂无"}`,
|
||
allowPolicy: `当前策略:${labelForProjectConflictAllowPolicy(primaryPolicy?.allowPolicy ?? null)}`,
|
||
conflictState: `冲突态:${labelForProjectConflictState(primaryPolicy?.conflictState ?? null)}`,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
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 resolvePostLoginPath() {
|
||
return window.location.hostname === "admin.boss.hyzq.net"
|
||
? "/"
|
||
: "/conversations";
|
||
}
|
||
|
||
function navigateAfterLogin(router: ReturnType<typeof useRouter>) {
|
||
const targetPath = resolvePostLoginPath();
|
||
router.replace(targetPath, { scroll: false });
|
||
router.refresh();
|
||
window.setTimeout(() => {
|
||
if (window.location.pathname !== targetPath) {
|
||
window.location.replace(targetPath);
|
||
}
|
||
}, 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]";
|
||
}
|
||
}
|
||
|
||
export function getConversationListItemPresentation(conversation: ConversationItem) {
|
||
const isFolderArchive = conversation.conversationType === "folder_archive";
|
||
return {
|
||
href:
|
||
isFolderArchive && conversation.folderKey
|
||
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
|
||
: `/conversations/${conversation.projectId}`,
|
||
title: conversation.projectTitle,
|
||
subtitle: isFolderArchive ? conversation.folderLabel : conversation.deviceNamesPreview.join(" / "),
|
||
};
|
||
}
|
||
|
||
export function getConversationActionAvailability(conversation: ConversationItem) {
|
||
return {
|
||
canTogglePin: false,
|
||
togglePinLabel: conversation.topPinnedLabel || conversation.manualPinned ? "取消置顶" : "置顶",
|
||
};
|
||
}
|
||
|
||
export function getConversationPinnedBadgeLabel(conversation: ConversationItem) {
|
||
void conversation;
|
||
return "";
|
||
}
|
||
|
||
export function getConversationActionsPath(projectId: string) {
|
||
return `/api/v1/conversations/${encodeURIComponent(projectId)}/actions`;
|
||
}
|
||
|
||
function ConversationActionButtons({
|
||
conversation,
|
||
}: {
|
||
conversation: ConversationItem;
|
||
}) {
|
||
const router = useRouter();
|
||
const [loading, setLoading] = useState<"toggle_pin" | "mark_read" | null>(null);
|
||
const actionAvailability = getConversationActionAvailability(conversation);
|
||
|
||
async function runAction(action: "toggle_pin" | "mark_read") {
|
||
setLoading(action);
|
||
await fetch(getConversationActionsPath(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">
|
||
{actionAvailability.canTogglePin ? (
|
||
<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]"
|
||
>
|
||
{actionAvailability.togglePinLabel}
|
||
</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) => {
|
||
const presentation = getConversationListItemPresentation(conversation);
|
||
const isFolderArchive = conversation.conversationType === "folder_archive";
|
||
|
||
return (
|
||
<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={presentation.href} 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]">{presentation.title}</div>
|
||
{isFolderArchive ? (
|
||
<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]">{presentation.subtitle}</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]">
|
||
{getConversationPinnedBadgeLabel(conversation)}
|
||
</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,
|
||
workspace,
|
||
}: {
|
||
device: Device;
|
||
relatedThreads: ThreadContextSnapshot[];
|
||
activeEnrollment?: DeviceEnrollment;
|
||
workspace: DeviceWorkspaceView;
|
||
}) {
|
||
const router = useRouter();
|
||
const detailCards = buildDeviceWorkspaceDetailCards(workspace);
|
||
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
|
||
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 [preferredExecutionMode, setPreferredExecutionMode] = useState<
|
||
Device["preferredExecutionMode"]
|
||
>(device.preferredExecutionMode ?? "cli");
|
||
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,
|
||
preferredExecutionMode,
|
||
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();
|
||
}
|
||
|
||
async function saveConflictDecision(decision: "forbid" | "allow_once" | "allow_always") {
|
||
if (!primaryPolicy?.projectId) {
|
||
setMessage("当前没有可操作的异常项目 / 文件夹。");
|
||
return;
|
||
}
|
||
const response = await fetch(`/api/v1/devices/${device.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
projectId: primaryPolicy.projectId,
|
||
folderKey: primaryPolicy.folderKey,
|
||
conflictDecision: decision,
|
||
}),
|
||
});
|
||
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>
|
||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.capabilities.title}</div>
|
||
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.gui}</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.cli}</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.browserAutomation}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.computerUse}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexAppServer}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexModels}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexExtensions}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexAccount}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexConfig}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexSkillRoots}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexHooks}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexThreads}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexTurns}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexThreadActions}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexPluginGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexAccountGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexConfigGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexFileSystemGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexCommandSession}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexExternalAgentGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexMarketplaceGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexExperimentalFeatureGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexReviewGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexWindowsSandboxGovernance}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.codexFuzzyFileSearch}
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||
{detailCards.capabilities.items.preferredExecutionMode}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="text-[12px] text-[#8C8C8C]">切换默认执行模式</div>
|
||
<div className="flex gap-2">
|
||
{(["gui", "cli"] as const).map((mode) => (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() => setPreferredExecutionMode(mode)}
|
||
className={clsx(
|
||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||
preferredExecutionMode === mode
|
||
? "bg-[#07C160] text-white"
|
||
: "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{mode === "gui" ? "GUI" : "CLI"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</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-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.conflicts.title}</div>
|
||
<div className="text-[12px] text-[#8C8C8C]">{detailCards.conflicts.headerHint}</div>
|
||
</div>
|
||
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.device}</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.folderKey}</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.projectId}</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.allowPolicy}</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.conflictState}</div>
|
||
</div>
|
||
<div className="text-[12px] text-[#8C8C8C]">{detailCards.conflicts.scopeLabel}</div>
|
||
{primaryPolicy ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveConflictDecision("forbid")}
|
||
className={clsx(
|
||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||
primaryPolicy.allowPolicy === "forbid" ? "bg-[#111111] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{detailCards.conflicts.actions[0]}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveConflictDecision("allow_once")}
|
||
className={clsx(
|
||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||
primaryPolicy.allowPolicy === "allow_once" ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{detailCards.conflicts.actions[1]}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveConflictDecision("allow_always")}
|
||
className={clsx(
|
||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||
primaryPolicy.allowPolicy === "allow_always" ? "bg-[#2563EB] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{detailCards.conflicts.actions[2]}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<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}
|
||
<ChatBubbleMarkdown body={message.body} mine={mine} green={green} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChatBubbleMarkdown({
|
||
body,
|
||
mine,
|
||
green,
|
||
}: {
|
||
body: string;
|
||
mine: boolean;
|
||
green: boolean;
|
||
}) {
|
||
const blocks = parseChatMarkdown(body);
|
||
|
||
return (
|
||
<div className="space-y-2 break-words">
|
||
{blocks.map((block, index) => (
|
||
<ChatMarkdownBlockView key={`${block.kind}-${index}`} block={block} mine={mine} green={green} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChatMarkdownBlockView({
|
||
block,
|
||
mine,
|
||
green,
|
||
}: {
|
||
block: ChatMarkdownBlock;
|
||
mine: boolean;
|
||
green: boolean;
|
||
}) {
|
||
const mutedClass = mine ? "text-white/82" : green ? "text-[#4E7A60]" : "text-[#57606A]";
|
||
const markerClass = mine ? "text-white/72" : green ? "text-[#44A064]" : "text-[#8C8C8C]";
|
||
|
||
switch (block.kind) {
|
||
case "heading":
|
||
return (
|
||
<div
|
||
className={clsx(
|
||
"font-semibold leading-6",
|
||
block.level === 1 ? "text-[16px]" : block.level === 2 ? "text-[15px]" : "text-[14px]",
|
||
)}
|
||
>
|
||
{block.text}
|
||
</div>
|
||
);
|
||
case "label":
|
||
return (
|
||
<div className="rounded-2xl bg-black/[0.035] px-3 py-2">
|
||
<div className={clsx("text-[12px] font-semibold", markerClass)}>{block.label}</div>
|
||
<div className="mt-1 whitespace-pre-wrap text-[14px] leading-6">{block.text}</div>
|
||
</div>
|
||
);
|
||
case "bullet":
|
||
return (
|
||
<div className="flex gap-2 leading-6">
|
||
<span className={markerClass}>•</span>
|
||
<span className="min-w-0 flex-1">{block.text}</span>
|
||
</div>
|
||
);
|
||
case "ordered":
|
||
return (
|
||
<div className="flex gap-2 leading-6">
|
||
<span className={clsx("tabular-nums", markerClass)}>{block.order}</span>
|
||
<span className="min-w-0 flex-1">{block.text}</span>
|
||
</div>
|
||
);
|
||
case "quote":
|
||
return (
|
||
<div className={clsx("border-l-2 pl-3 text-[14px] leading-6", mine ? "border-white/50" : "border-[#D8DEE4]", mutedClass)}>
|
||
{block.text}
|
||
</div>
|
||
);
|
||
case "code":
|
||
return (
|
||
<pre
|
||
className={clsx(
|
||
"overflow-x-auto rounded-2xl px-3 py-2 text-[12px] leading-5",
|
||
mine ? "bg-white/16 text-white" : "bg-[#F2F3F5] text-[#24292F]",
|
||
)}
|
||
>
|
||
<code>{block.text}</code>
|
||
</pre>
|
||
);
|
||
case "paragraph":
|
||
default:
|
||
return <div className="whitespace-pre-wrap leading-6">{block.text}</div>;
|
||
}
|
||
}
|
||
|
||
export function ProjectHeaderActions({ projectId }: { projectId: string }) {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<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>
|
||
<Link
|
||
href={`/conversations/${projectId}/thread-status`}
|
||
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
|
||
>
|
||
线程状态
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function orchestrationBackendChoiceLabel(choice: ProjectOrchestrationBackendState["availableChoices"][number]) {
|
||
return choice.backendId === "boss-native-orchestrator"
|
||
? "Boss Native Orchestrator"
|
||
: "OMX Team Runtime";
|
||
}
|
||
|
||
function normalizeOrchestrationReasonLabel(value: string) {
|
||
const trimmed = value.trim();
|
||
if (trimmed.endsWith("。") || trimmed.endsWith(".")) {
|
||
return trimmed.slice(0, -1);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
function orchestrationBackendAvailabilityCopy(
|
||
state: ProjectOrchestrationBackendState,
|
||
fallbackActive: boolean,
|
||
) {
|
||
if (state.omxAvailability.selectable) {
|
||
return {
|
||
badge: "正常",
|
||
summary: "OMX Team Runtime 当前可用,当前可切换到该后端。",
|
||
};
|
||
}
|
||
|
||
return {
|
||
badge: fallbackActive ? "已回退" : "OMX 受限",
|
||
summary: fallbackActive
|
||
? `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},当前已自动回退到 Boss Native Orchestrator。`
|
||
: `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},切换后会自动回退到 Boss Native Orchestrator。`,
|
||
};
|
||
}
|
||
|
||
export function ProjectOrchestrationBackendCard({
|
||
projectId,
|
||
initialState,
|
||
}: {
|
||
projectId: string;
|
||
initialState: ProjectOrchestrationBackendState;
|
||
}) {
|
||
const router = useRouter();
|
||
const [state, setState] = useState(initialState);
|
||
const [savingBackendId, setSavingBackendId] = useState<OrchestrationBackendId | null>(null);
|
||
const [message, setMessage] = useState("");
|
||
|
||
const fallbackActive = state.requestedBackendId !== state.currentBackendId;
|
||
const availabilityCopy = orchestrationBackendAvailabilityCopy(state, fallbackActive);
|
||
|
||
async function saveBackend(requestedBackendId: OrchestrationBackendId) {
|
||
setSavingBackendId(requestedBackendId);
|
||
const response = await fetch(`/api/v1/projects/${projectId}/orchestration-backend`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ requestedBackendId }),
|
||
});
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message?: string;
|
||
currentBackendId?: OrchestrationBackendId;
|
||
currentBackendLabel?: string;
|
||
requestedBackendId?: OrchestrationBackendId;
|
||
requestedBackendLabel?: string;
|
||
availableChoices?: ProjectOrchestrationBackendState["availableChoices"];
|
||
omxAvailability?: ProjectOrchestrationBackendState["omxAvailability"];
|
||
};
|
||
setSavingBackendId(null);
|
||
if (
|
||
!result.ok ||
|
||
!result.currentBackendId ||
|
||
!result.currentBackendLabel ||
|
||
!result.requestedBackendId ||
|
||
!result.requestedBackendLabel ||
|
||
!result.availableChoices ||
|
||
!result.omxAvailability
|
||
) {
|
||
setMessage(result.message ?? "保存失败");
|
||
return;
|
||
}
|
||
setState({
|
||
projectId,
|
||
currentBackendId: result.currentBackendId,
|
||
currentBackendLabel: result.currentBackendLabel,
|
||
requestedBackendId: result.requestedBackendId,
|
||
requestedBackendLabel: result.requestedBackendLabel,
|
||
availableChoices: result.availableChoices,
|
||
omxAvailability: result.omxAvailability,
|
||
});
|
||
setMessage(
|
||
requestedBackendId === "omx-team"
|
||
? "已切换到 OMX Team Runtime。"
|
||
: "已切换回 Boss Native Orchestrator。",
|
||
);
|
||
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-[14px] font-semibold text-[#111111]">编排后端</div>
|
||
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">
|
||
当前生效:{state.currentBackendLabel}
|
||
<br />
|
||
当前请求:{state.requestedBackendLabel}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={clsx(
|
||
"rounded-full px-3 py-1 text-[11px] font-semibold",
|
||
fallbackActive || !state.omxAvailability.selectable
|
||
? "bg-[#FFF7E6] text-[#D46B08]"
|
||
: "bg-[#EAF7F0] text-[#215B39]",
|
||
)}
|
||
>
|
||
{availabilityCopy.badge}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 grid gap-2">
|
||
{state.availableChoices.map((choice) => {
|
||
const active = choice.current;
|
||
const selectable = choice.selectable && savingBackendId !== choice.backendId;
|
||
return (
|
||
<button
|
||
key={choice.backendId}
|
||
type="button"
|
||
onClick={() => void saveBackend(choice.backendId)}
|
||
disabled={!selectable}
|
||
className={clsx(
|
||
"flex items-center justify-between rounded-2xl border px-4 py-3 text-left",
|
||
active ? "border-[#07C160] bg-[#F5FFF8]" : "border-[#E5E5EA] bg-[#F7F8FA]",
|
||
!choice.selectable ? "opacity-70" : "",
|
||
)}
|
||
>
|
||
<div>
|
||
<div className="text-[14px] font-semibold text-[#111111]">
|
||
{orchestrationBackendChoiceLabel(choice)}
|
||
</div>
|
||
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">{choice.label}</div>
|
||
</div>
|
||
<div className="text-right text-[11px] text-[#8C8C8C]">
|
||
<div>{active ? "当前" : "切换"}</div>
|
||
{!choice.selectable ? <div>不可选</div> : null}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div
|
||
className={clsx(
|
||
"mt-3 rounded-2xl px-4 py-3 text-[12px] leading-6",
|
||
state.omxAvailability.selectable
|
||
? "bg-[#EAF7F0] text-[#215B39]"
|
||
: "bg-[#FFF7E6] text-[#8D5D00]",
|
||
)}
|
||
>
|
||
{availabilityCopy.summary}
|
||
</div>
|
||
{message ? (
|
||
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
export function MasterAgentChatMenu({ projectId }: { projectId: string }) {
|
||
const router = useRouter();
|
||
const items = getMasterAgentChatMenuItems(projectId);
|
||
|
||
if (items.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<details className="relative">
|
||
<summary
|
||
className="flex h-9 w-9 list-none items-center justify-center rounded-full border border-[#E5E5EA] bg-white text-[18px] font-semibold leading-none text-[#57606A] shadow-sm marker:content-none"
|
||
aria-label="更多"
|
||
>
|
||
…
|
||
</summary>
|
||
<div className="absolute right-0 top-11 z-20 min-w-[116px] rounded-2xl border border-[#E5E5EA] bg-white p-2 shadow-[0_12px_28px_rgba(15,23,42,0.14)]">
|
||
{items.map((item) =>
|
||
item.href ? (
|
||
<Link
|
||
key={item.key}
|
||
href={item.href}
|
||
className="flex rounded-xl px-3 py-2 text-[13px] text-[#111111] hover:bg-[#F7F8FA]"
|
||
>
|
||
{item.label}
|
||
</Link>
|
||
) : (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => {
|
||
if (item.action === "refresh") {
|
||
router.refresh();
|
||
}
|
||
}}
|
||
className="flex w-full rounded-xl px-3 py-2 text-left text-[13px] text-[#111111] hover:bg-[#F7F8FA]"
|
||
>
|
||
{item.label}
|
||
</button>
|
||
),
|
||
)}
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
type PendingDispatchPlanState = {
|
||
planId: string;
|
||
summary?: string;
|
||
targets: Array<{ projectId: string; threadDisplayName: string }>;
|
||
};
|
||
|
||
export function ChatComposer({
|
||
projectId,
|
||
initialPendingDispatchPlan,
|
||
initialRejectedDispatchPlan,
|
||
dispatchPlanRecoveryHint,
|
||
initialLightDispatchReminderEnabled = false,
|
||
}: {
|
||
projectId: string;
|
||
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
|
||
initialRejectedDispatchPlan?: PendingDispatchPlanState | null;
|
||
dispatchPlanRecoveryHint?: string | null;
|
||
initialLightDispatchReminderEnabled?: boolean;
|
||
}) {
|
||
const router = useRouter();
|
||
type ComposerMessageKind = "text" | "voice_intent" | "image_intent" | "video_intent";
|
||
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 [localRejectedDispatchPlan, setLocalRejectedDispatchPlan] =
|
||
useState<PendingDispatchPlanState | null>(null);
|
||
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(null);
|
||
const [lightDispatchReminderEnabled, setLightDispatchReminderEnabled] = useState(
|
||
initialLightDispatchReminderEnabled,
|
||
);
|
||
const [threadExecutionConflict, setThreadExecutionConflict] = useState<{
|
||
conflict: ThreadConversationExecutionConflict;
|
||
draftBody: string;
|
||
kind: ComposerMessageKind;
|
||
} | null>(null);
|
||
const pendingDispatchPlan =
|
||
localPendingDispatchPlan ??
|
||
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
|
||
? initialPendingDispatchPlan
|
||
: null);
|
||
const rejectedDispatchPlan =
|
||
pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null;
|
||
const threadExecutionConflictDescription = threadExecutionConflict
|
||
? describeThreadConversationExecutionConflict(threadExecutionConflict.conflict)
|
||
: null;
|
||
|
||
async function confirmDispatchPlan(rememberLightReminder = false) {
|
||
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),
|
||
rememberLightReminder,
|
||
}),
|
||
},
|
||
);
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
executions?: Array<unknown>;
|
||
collaborationGate?: {
|
||
lightDispatchReminderEnabled?: boolean;
|
||
};
|
||
message?: string;
|
||
};
|
||
setLoading(false);
|
||
if (!result.ok) {
|
||
setMessageTone("error");
|
||
setMessage(result.message ?? "确认下发失败,请重试。");
|
||
return;
|
||
}
|
||
const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length;
|
||
setLightDispatchReminderEnabled(
|
||
result.collaborationGate?.lightDispatchReminderEnabled ?? lightDispatchReminderEnabled,
|
||
);
|
||
setLocalPendingDispatchPlan(null);
|
||
setLocalRejectedDispatchPlan(null);
|
||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||
setMessageTone("success");
|
||
setMessage(
|
||
rememberLightReminder
|
||
? `已确认下发到 ${executionCount} 个线程,并记住这个群使用轻提醒。`
|
||
: `已确认下发到 ${executionCount} 个线程。`,
|
||
);
|
||
router.refresh();
|
||
}
|
||
|
||
async function retryDispatchPlan() {
|
||
if (!rejectedDispatchPlan) return;
|
||
setLoading(true);
|
||
const response = await fetch(
|
||
`/api/v1/projects/${projectId}/dispatch-plans/${rejectedDispatchPlan.planId}/retry`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
},
|
||
);
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
dispatchPlan?: {
|
||
planId: string;
|
||
summary?: string;
|
||
targets?: Array<{ projectId: string; threadDisplayName: string }>;
|
||
} | null;
|
||
collaborationGate?: {
|
||
requiresMasterAgentApproval?: boolean;
|
||
lightDispatchReminderEnabled?: boolean;
|
||
};
|
||
message?: string;
|
||
};
|
||
setLoading(false);
|
||
if (!result.ok) {
|
||
setMessageTone("error");
|
||
setMessage(result.message ?? "重新生成推荐失败,请稍后重试。");
|
||
return;
|
||
}
|
||
setLocalRejectedDispatchPlan(null);
|
||
setLocalPendingDispatchPlan(
|
||
result.dispatchPlan
|
||
? {
|
||
planId: result.dispatchPlan.planId,
|
||
summary: result.dispatchPlan.summary,
|
||
targets: result.dispatchPlan.targets ?? [],
|
||
}
|
||
: null,
|
||
);
|
||
setDismissedPendingPlanId(null);
|
||
setLightDispatchReminderEnabled(
|
||
result.collaborationGate?.lightDispatchReminderEnabled ?? lightDispatchReminderEnabled,
|
||
);
|
||
setMessageTone("success");
|
||
setMessage(
|
||
result.collaborationGate?.requiresMasterAgentApproval
|
||
? "主 Agent 已重新生成推荐,等待你确认下发。"
|
||
: "主 Agent 已重新生成推荐。",
|
||
);
|
||
router.refresh();
|
||
}
|
||
|
||
async function rejectDispatchPlan() {
|
||
if (!pendingDispatchPlan) return;
|
||
setLoading(true);
|
||
const response = await fetch(
|
||
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/reject`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
},
|
||
);
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
plan?: {
|
||
planId: string;
|
||
summary?: string;
|
||
targets?: Array<{ projectId: string; threadDisplayName: string }>;
|
||
} | null;
|
||
message?: string;
|
||
};
|
||
setLoading(false);
|
||
if (!result.ok) {
|
||
setMessageTone("error");
|
||
setMessage(result.message ?? "拒绝失败,请稍后重试。");
|
||
return;
|
||
}
|
||
setLocalPendingDispatchPlan(null);
|
||
setLocalRejectedDispatchPlan(
|
||
result.plan
|
||
? {
|
||
planId: result.plan.planId,
|
||
summary: result.plan.summary,
|
||
targets: result.plan.targets ?? pendingDispatchPlan.targets,
|
||
}
|
||
: pendingDispatchPlan,
|
||
);
|
||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||
setMessageTone("success");
|
||
setMessage("已拒绝主 Agent 推荐。");
|
||
router.refresh();
|
||
}
|
||
|
||
async function send(
|
||
kind: ComposerMessageKind,
|
||
options?: {
|
||
draftBody?: string;
|
||
},
|
||
) {
|
||
const draftBody = kind === "text" ? (options?.draftBody ?? value).trim() : "";
|
||
if (kind === "text" && !draftBody) {
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ body: kind === "text" ? draftBody : undefined, kind }),
|
||
});
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message?: { body: string } | string;
|
||
dispatchPlan?: {
|
||
planId: string;
|
||
summary?: string;
|
||
targets?: Array<{ projectId: string; threadDisplayName: string }>;
|
||
} | null;
|
||
collaborationGate?: {
|
||
requiresMasterAgentApproval?: boolean;
|
||
lightDispatchReminderEnabled?: boolean;
|
||
};
|
||
code?: string;
|
||
executionConflict?: ThreadConversationExecutionConflict;
|
||
messageText?: string;
|
||
};
|
||
setLoading(false);
|
||
if (!result.ok && response.status === 409 && result.code === "THREAD_EXECUTION_CONFLICT" && result.executionConflict) {
|
||
setThreadExecutionConflict({
|
||
conflict: result.executionConflict,
|
||
draftBody,
|
||
kind,
|
||
});
|
||
setMessageTone("error");
|
||
setMessage(typeof result.message === "string" ? result.message : "当前线程命中了 GUI / CLI 冲突保护。");
|
||
return;
|
||
}
|
||
if (result.ok) {
|
||
setThreadExecutionConflict(null);
|
||
void sendAppLog({
|
||
deviceId: boundDeviceIdFromDom(),
|
||
projectId,
|
||
level: "info",
|
||
category: "chat.message_sent",
|
||
message:
|
||
kind === "text" ? `已发送文本消息:${draftBody || "空文本"}` : `已发送 ${kind} 意图消息。`,
|
||
mirrorToMaster: false,
|
||
});
|
||
setValue("");
|
||
if (result.dispatchPlan) {
|
||
setLocalPendingDispatchPlan({
|
||
planId: result.dispatchPlan.planId,
|
||
summary: result.dispatchPlan.summary,
|
||
targets: result.dispatchPlan.targets ?? [],
|
||
});
|
||
setLocalRejectedDispatchPlan(null);
|
||
setDismissedPendingPlanId(null);
|
||
setLightDispatchReminderEnabled(
|
||
result.collaborationGate?.lightDispatchReminderEnabled ?? lightDispatchReminderEnabled,
|
||
);
|
||
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(typeof result.message === "string" ? result.message : "消息发送失败,请重试。");
|
||
}
|
||
|
||
async function handleThreadExecutionConflictDecision(
|
||
decision: ThreadConversationExecutionConflictAction,
|
||
) {
|
||
if (!threadExecutionConflict) {
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
const response = await fetch(`/api/v1/devices/${threadExecutionConflict.conflict.deviceId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
projectId: threadExecutionConflict.conflict.projectId,
|
||
folderKey: threadExecutionConflict.conflict.folderKey ?? null,
|
||
conflictDecision: decision,
|
||
}),
|
||
});
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message?: string;
|
||
};
|
||
setLoading(false);
|
||
if (!result.ok) {
|
||
setMessageTone("error");
|
||
setMessage(result.message ?? "冲突放行设置失败,请重试。");
|
||
return;
|
||
}
|
||
|
||
const pendingDraft = threadExecutionConflict;
|
||
setThreadExecutionConflict(null);
|
||
setMessageTone(decision === "forbid" ? "error" : "success");
|
||
setMessage(summarizeThreadConversationExecutionDecisionResult(decision));
|
||
if (decision === "forbid") {
|
||
return;
|
||
}
|
||
await send(pendingDraft.kind, { draftBody: pendingDraft.draftBody });
|
||
}
|
||
|
||
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}
|
||
{dispatchPlanRecoveryHint ? (
|
||
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
{dispatchPlanRecoveryHint}
|
||
</div>
|
||
) : null}
|
||
{threadExecutionConflict && threadExecutionConflictDescription ? (
|
||
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
|
||
<div className="text-[14px] font-semibold text-[#111111]">
|
||
{threadExecutionConflictDescription.title}
|
||
</div>
|
||
<div className="mt-2">{threadExecutionConflictDescription.summary}</div>
|
||
<div className="mt-2 text-[12px] text-[#8C8C8C]">
|
||
设备:{threadExecutionConflict.conflict.deviceName}
|
||
{" · "}
|
||
默认模式:{threadExecutionConflict.conflict.preferredExecutionMode === "gui" ? "GUI" : "CLI"}
|
||
{threadExecutionConflict.conflict.folderKey ? ` · ${threadExecutionConflict.conflict.folderKey}` : ""}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{threadExecutionConflict.conflict.actions.map((action) => (
|
||
<button
|
||
key={action}
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void handleThreadExecutionConflictDecision(action)}
|
||
className={clsx(
|
||
"rounded-full px-4 py-2 text-[13px] font-semibold disabled:opacity-60",
|
||
action === "allow_once"
|
||
? "bg-[#07C160] text-white"
|
||
: action === "allow_always"
|
||
? "bg-[#2563EB] text-white"
|
||
: "border border-[#F0B5B5] text-[#CF1322]",
|
||
)}
|
||
>
|
||
{labelForThreadConversationExecutionConflictDecision(action)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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]">
|
||
{lightDispatchReminderEnabled ? summarizeDispatchPlanLightTitle(pendingDispatchPlan) : "主 Agent 推荐下发"}
|
||
</div>
|
||
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlanCompact(pendingDispatchPlan)}</div>
|
||
<div className="mt-2 text-[12px] text-[#8C8C8C]">
|
||
{lightDispatchReminderEnabled ? "轻提醒已开启" : "当前仍会显式提醒你确认"}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void confirmDispatchPlan(false)}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||
>
|
||
{lightDispatchReminderEnabled ? "继续下发" : "确认一下"}
|
||
</button>
|
||
{!lightDispatchReminderEnabled ? (
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void confirmDispatchPlan(true)}
|
||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A]"
|
||
>
|
||
确认并记住
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void rejectDispatchPlan()}
|
||
className="rounded-full border border-[#F0B5B5] px-4 py-2 text-[13px] font-semibold text-[#CF1322]"
|
||
>
|
||
拒绝
|
||
</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}
|
||
{rejectedDispatchPlan ? (
|
||
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
|
||
<div className="text-[14px] font-semibold text-[#111111]">上次推荐已拒绝</div>
|
||
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(rejectedDispatchPlan)}</div>
|
||
<div className="mt-2">
|
||
{dispatchPlanRecoveryHint ??
|
||
"如果还想继续当前协作,可以直接重新生成新的推荐,不用把整条需求重新打一遍。"}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => void retryDispatchPlan()}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||
>
|
||
重新生成新的推荐
|
||
</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);
|
||
navigateAfterLogin(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]">
|
||
验证码会按当前服务器配置发送;如果企业仍处于固定验证码演示模式,请以管理员配置为准。
|
||
</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("krisolo");
|
||
const [projects, setProjects] = useState("");
|
||
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: 15000,
|
||
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>
|
||
);
|
||
}
|