Merge pull request 'feat(onboard/parity): UI 一键接入面板——先计划预览、人确认后接入,四个入口接线(P8g)' (#41) from feat/agent-client-parity-p8g into master
All checks were successful
Push & PR Check / check (push) Successful in 1m19s
All checks were successful
Push & PR Check / check (push) Successful in 1m19s
This commit is contained in:
commit
3228b490f2
@ -331,13 +331,16 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
|
||||
- review: "2026-10-03 独立只读审查 bd4ebaf,结论 fix_then_merge,10 条发现处置:M1 [中] MCP 入参原样落库(正斜杠、尾部分隔符、长路径前缀、8.3、`..`),scan_unregistered_projects 按字面比较会自相矛盾 → 已修:本体对 Windows 目录 canonicalize 后去长路径前缀落库,register_project_at 回执 path 为落库值、名称缺省按规范形式取目录名(相对路径不 canonicalize,交本体拒绝);scan 的已登记判定与父目录去重改用 registration_path_key。M2 [中] UI 不选目录的概念占位被取消,超出 P8f 范围且与 mcp-server 模块记录的用户用法冲突 → 已恢复原行为:本体新增 RootRequirement,UI 入口有效路径为空时只写 profile+workspace、不校验目录、不查重复,MCP 入口仍拒绝;是否改为显式占位概念交统筹会话问用户(需求澄清),本 PR 不预设答案。L1 [低] 已有登记两列都比,残留列误拦 → 已修:按 platform 只取有效根目录(registration_root),查询补 platform 列。L2 [低] 8.3、`..`、末尾点等漏判 → 已修:新目录在写事务外另算 canonical 键,两键比对已有行的字面键(已有行不读磁盘,写锁内无 I/O)。L3 [低] mount_repo 检查在插 profile 之后且无事务 → 已修:抽 mount_repo_records(连接注入),检查前移、整体 IMMEDIATE 事务;前端先 gitClone 再 mount 的白克隆属 UI 范围,未改。L4 [低] 路径比较键多份实现 → 部分修:registration_path_key 改为复用 mcp_inject::normalize_dsh_root + strip_verbatim_prefix(两函数改 pub(crate));normalize_trust_path、WSL 发行版查询与 UNC 换算的第三份拷贝统一抽 crate::path_key 转后续小修(会动 P4a/P8d 热点 mcp_inject.rs)。I1 [信息] update_project 仍可造同目录登记 → 转后续小修:UI 编辑整表提交,须「有效根目录实际变化才检查 + 排除自身」,否则存量重复登记(beast-mode)连改名都会被拦,单独做更稳。I2 [信息] project_root_with_conn 对 UNC 形式的 wsl_path 错误拼接(已有缺陷)→ 不在本 PR 改(mcp_inject.rs 热点),转后续小修;卡片与代码注释里「同口径」改为只指取列与 Linux 路径换算。I3 [信息] 重登目录遗留旧 context.json 无提示 → 已修:register_project_at 只读检查,project_id 不同时回执与摘要带 warning(不沿用旧 ID)。I4 [信息] 分发分支无测试 → 已修:分发抽成 register_project_call(conn, args),测试按 schema 声明的参数名构造入参;IMMEDIATE 并发仍无测试(见没验什么③)。"
|
||||
- mcp_verify: "PR #37 合并(55dc637);P8e 合并后经全局 MCP 实测链路 register_project → plan_onboarding → onboard_project 得 status=done(证据同 P8e mcp_verify);同目录换写法再登记被拒并给出已有 project_id。"
|
||||
|
||||
### 📋 P8g — UI 一键接入
|
||||
- status: todo
|
||||
### 🔵 P8g — UI 一键接入
|
||||
- status: in_progress
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/OnboardPanel.tsx, src/components/dashboard/onboardView.ts, src/components/dashboard/onboardView.test.ts, src/components/dashboard/ProjectModal.tsx, src/components/dashboard/BatchAddModal.tsx, src/components/manage/NewProjectModal.tsx, src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
|
||||
- depends: P8e
|
||||
- acceptance: "agent_verify: [pnpm typecheck && pnpm test] 视图纯函数:blocked 非空时确认按钮禁用并列出文件和原因;not_installed 和 not_applicable 显示为灰色说明、不显示为红色失败;bypassPermissions 和 Codex 信任单独列出;确认时携带 expected_digest。human_verify: ① 用临时 git 仓库新建项目,弹出接入预览;② 确认后 30 秒内看到分步结果、L1 矩阵和「待你提交的 N 个文件」;③ 另试一次在预览里取消,目录零改动;④ 在该目录新开 Claude Code、Codex、DSH 桌面 GUI 会话调 LJ_PROBE_NONEXISTENT,得「开发需求未登记」。"
|
||||
- 说明:新建 / 模板创建成功后弹出「接入预览」,create_project 本身保持无副作用;批量添加只提供批量入口、不自动接入;BlueprintModal 的「应用接入包」改为先 plan 再 apply。
|
||||
- 实现(2026-10-04,分支 feat/agent-client-parity-p8g,统筹会话主线程直做——两次委派卡死于一次性生成大文件):onboardView.ts 视图纯函数(计划预览、确认请求、报告、L1 矩阵;判定全取后端字段);OnboardPanel.tsx 全局挂载于 App、经 useUIStore.openOnboard 打开(新建弹窗保存即卸载,面板须独立存在),状态机 planning → preview → applying → report,过期响应按请求序号丢弃、接入中不可关闭,refused 附新计划可回预览重新确认;plan.rs 的 BlockedItem 序列化附派生字段 skippable(可跳过判定只在本体,反序列化忽略、不进 digest),commands.ts 同步;入口:ProjectModal 新建且按 platform 的有效根目录非空才弹(概念占位不弹),NewProjectModal 模板创建成功自动弹、完成页可再开,BatchAddModal 添加后列出有目录的新项目逐个「一键接入」,BlueprintModal「施加 Buff」改为同一面板(buff 已激活时另给「重新检查接入」),关闭后按报告刷新 buff 与 init prompt;project-context.yaml 同步 skippable 与 UI 投影。
|
||||
- 有意偏差:"① 呈现块拆到 OnboardSections.tsx(files 外新增,控制单文件规模)。② Toast 层级 z-50 → z-[70](审查 ②,否则接入提示被面板遮住)。③ 本地 confirm 函数改名 applyConfirmed:confirmUsage.test.ts 扫描 confirm( 防同步确认框,属正确的守卫。"
|
||||
- review: "2026-10-04 独立只读审查 5b6c197,结论 merge,5 条:① [低] 新建入口按任一路径非空判是否弹面板,与后端按 platform 取有效根目录不同口径 → 已修。② [低] Toast z-50 被面板 z-[60] 遮住 → 已修。③ [低] 已有 permissions 又取消 bypass 时文案称会写 default → 已改文案(如实说明两种情况)。④ [信息] L1 格子着色在前端写死 codex_project_untrusted=待你操作,轻度复制判定 → 转后续小修(L1 格子带 pending_user 字段)。⑤ [信息] onboard_project 抛错后「取消」提示仍称不写入 → 已修(错误阶段换提示)。审查核实:确认带 plan.digest 与生成计划时的选项;refused 回预览时 planned 与新计划同一组选项(apply.rs 用请求选项重算);四条 agent_verify 有实断言。"
|
||||
|
||||
### ✅ P9 — DSH 桌面 GUI 按工作区连项目地址(会话级插件)
|
||||
- status: done
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -121,13 +121,27 @@ impl BlockReason {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct BlockedItem {
|
||||
pub target: String,
|
||||
pub reason: BlockReason,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 序列化时附派生字段 skippable(= BlockReason::skippable):UI 与 agent 据此决定是否给「跳过」,
|
||||
/// 可跳过判定只在本体一处(P8g,AGENTS.md「边界单点实现」)。反序列化忽略该字段;digest 不含它。
|
||||
impl Serialize for BlockedItem {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut item = serializer.serialize_struct("BlockedItem", 4)?;
|
||||
item.serialize_field("target", &self.target)?;
|
||||
item.serialize_field("reason", &self.reason)?;
|
||||
item.serialize_field("detail", &self.detail)?;
|
||||
item.serialize_field("skippable", &self.reason.skippable())?;
|
||||
item.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置。
|
||||
/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | unavailable
|
||||
/// | manual_kept | other_project | needs_human(后三者阻断确认,见 machine_step_blocker)。
|
||||
@ -1040,6 +1054,28 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 阻断项序列化带派生字段 skippable(P8g 界面据此给「跳过」,不在前端复制判定);反序列化忽略它。
|
||||
#[test]
|
||||
fn blocked_item_serializes_skippable_from_reason() {
|
||||
for (reason, skippable) in [
|
||||
(BlockReason::ManualEntry, true),
|
||||
(BlockReason::SharedKey, true),
|
||||
(BlockReason::MixedFile, false),
|
||||
(BlockReason::IdentityConflict, false),
|
||||
(BlockReason::NeedsHuman, false),
|
||||
] {
|
||||
let item = BlockedItem {
|
||||
target: "t".into(),
|
||||
reason,
|
||||
detail: "d".into(),
|
||||
};
|
||||
let value = serde_json::to_value(&item).unwrap();
|
||||
assert_eq!(value["skippable"], skippable, "{reason:?}");
|
||||
assert_eq!(value["target"], "t");
|
||||
assert_eq!(serde_json::from_value::<BlockedItem>(value).unwrap(), item);
|
||||
}
|
||||
}
|
||||
|
||||
/// 身份冲突、混合文件与需人工处理的项不可跳过(P8d 审查 ②):即使列进 skip_targets 也照旧阻断。
|
||||
#[test]
|
||||
fn identity_conflict_and_needs_human_cannot_be_skipped() {
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import { OverviewPage } from "./components/overview/OverviewPage";
|
||||
import { Dashboard } from "./components/dashboard/Dashboard";
|
||||
import { ProjectModal } from "./components/dashboard/ProjectModal";
|
||||
import { OnboardPanel } from "./components/dashboard/OnboardPanel";
|
||||
import { ResourcesPage } from "./components/resources/ResourcesPage";
|
||||
import { ToolsPage } from "./components/tools/ToolsPage";
|
||||
import { SettingsPage } from "./components/settings/SettingsPage";
|
||||
@ -75,6 +76,7 @@ export default function App() {
|
||||
</main>
|
||||
|
||||
<ProjectModal />
|
||||
<OnboardPanel />
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,10 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { batchCreateProjects, getSpaces, scanSubfolders, type SubFolder } from "../../lib/commands";
|
||||
import { batchCreateProjects, getSpaces, scanSubfolders, type OnboardStatus, type SubFolder } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
|
||||
/** 本次添加成功的项目:批量只给入口、不自动接入(P8g),每个项目各自预览确认 */
|
||||
interface AddedProject {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 本次面板里的接入结果;cancelled = 打开后未执行接入 */
|
||||
onboard?: OnboardStatus | "cancelled";
|
||||
}
|
||||
|
||||
const ONBOARD_STATUS_LABEL: Record<OnboardStatus | "cancelled", string> = {
|
||||
done: "✓ 接入完成",
|
||||
partial: "⚠ 部分完成",
|
||||
failed: "✗ 预检失败",
|
||||
refused: "⚠ 已拒绝,需重新确认",
|
||||
busy: "… 正在接入中",
|
||||
cancelled: "未接入",
|
||||
};
|
||||
|
||||
function AddedProjects({ added, onOnboard }: { added: AddedProject[]; onOnboard: (project: AddedProject) => void }) {
|
||||
return (
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
<p className="text-sm text-gray-700">已添加 {added.length} 个项目。一键接入需逐个预览并确认,不会自动写入子项目:</p>
|
||||
{added.map((project) => (
|
||||
<div key={project.id} className="flex items-center gap-3 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<span className="flex-1 min-w-0 truncate text-sm text-gray-800">{project.name}</span>
|
||||
<span className="text-xs text-gray-500">{project.onboard ? ONBOARD_STATUS_LABEL[project.onboard] : "未接入"}</span>
|
||||
<button
|
||||
onClick={() => onOnboard(project)}
|
||||
className="px-3 py-1 rounded-lg border border-blue-200 text-xs text-blue-700 hover:bg-blue-50 shrink-0"
|
||||
>
|
||||
{project.onboard === "done" ? "重新检查" : "一键接入"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Windows 路径转 WSL 路径(仅 /mnt/x/... 形式,不处理 UNC)
|
||||
function winToWsl(p: string): string {
|
||||
const norm = p.replace(/\\/g, "/");
|
||||
@ -22,7 +59,19 @@ interface FolderItem extends SubFolder {
|
||||
|
||||
export function BatchAddModal({ onClose }: { onClose: () => void }) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
const qc = useQueryClient();
|
||||
const [added, setAdded] = useState<AddedProject[] | null>(null);
|
||||
|
||||
const onboardOne = (project: AddedProject) =>
|
||||
openOnboard({
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
onClosed: (report) =>
|
||||
setAdded((prev) =>
|
||||
prev?.map((p) => (p.id === project.id ? { ...p, onboard: report?.status ?? p.onboard ?? "cancelled" } : p)) ?? prev,
|
||||
),
|
||||
});
|
||||
|
||||
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
|
||||
const currentSpace = spaces.find((s) => s.is_current);
|
||||
@ -103,7 +152,12 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) {
|
||||
} else {
|
||||
showToast(`成功添加 ${selected.length} 个项目 ✓`);
|
||||
}
|
||||
onClose();
|
||||
// 没有目录的登记(概念占位)不给接入入口
|
||||
const created = payload
|
||||
.filter((p) => !skipped.includes(p.id) && (p.win_path || p.wsl_path))
|
||||
.map((p) => ({ id: p.id, name: p.name }));
|
||||
if (created.length > 0) setAdded(created);
|
||||
else onClose();
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
@ -113,6 +167,19 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const checkedCount = items.filter((i) => i.checked).length;
|
||||
|
||||
if (added) {
|
||||
return (
|
||||
<Dialog title="批量添加项目" onClose={onClose} size="lg">
|
||||
<AddedProjects added={added} onOnboard={onboardOne} />
|
||||
<div className="flex justify-end px-6 py-4 border-t border-gray-100">
|
||||
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="批量添加项目" onClose={onClose} size="lg">
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
|
||||
@ -20,7 +20,6 @@ import {
|
||||
getBlueprintStatus,
|
||||
syncBlueprintRules,
|
||||
generateBlueprintPrompt,
|
||||
applyOnboardingPack,
|
||||
getCommitStats,
|
||||
removeBlueprintBuff,
|
||||
getBuffStatus,
|
||||
@ -31,10 +30,10 @@ import {
|
||||
type BlueprintStatus,
|
||||
type SyncResult,
|
||||
type BuffStatus,
|
||||
type TemplateResult,
|
||||
type HookActivation,
|
||||
type CommitStats,
|
||||
type OnboardReport,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
@ -54,19 +53,6 @@ const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }
|
||||
abandoned: { color: "#52525B", bg: "#FAFAFA", label: "已退役" },
|
||||
};
|
||||
|
||||
// 接入包模板分发五态(对应 onboarding.rs TemplateResult.action)
|
||||
// label 为 null 时展示后端 detail(updated/appended/failed 的 detail 含版本信息)
|
||||
const TEMPLATE_ACTION_STYLE: Record<
|
||||
string,
|
||||
{ icon: string; color: string; label: string | null }
|
||||
> = {
|
||||
written: { icon: "✓", color: "text-green-600", label: "已写入" },
|
||||
updated: { icon: "↑", color: "text-blue-600", label: null },
|
||||
appended: { icon: "+", color: "text-blue-600", label: null },
|
||||
skipped: { icon: "○", color: "text-gray-400", label: "已存在" },
|
||||
failed: { icon: "✗", color: "text-red-600", label: null },
|
||||
};
|
||||
|
||||
const TASK_PREFIX: Record<string, string> = {
|
||||
done: "✅",
|
||||
in_progress: "🔵",
|
||||
@ -1058,8 +1044,9 @@ function GovernancePanel({
|
||||
const [buffError, setBuffError] = useState<string | null>(null);
|
||||
const [buffInitPrompt, setBuffInitPrompt] = useState<string | null>(null);
|
||||
const [buffInitCopied, setBuffInitCopied] = useState(false);
|
||||
const [packTemplates, setPackTemplates] = useState<TemplateResult[] | null>(null);
|
||||
const [packHook, setPackHook] = useState<HookActivation | null>(null);
|
||||
/** 最近一次一键接入的结果摘要(详情在接入面板里看过) */
|
||||
const [onboardSummary, setOnboardSummary] = useState<string | null>(null);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
|
||||
const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@ -1067,28 +1054,29 @@ function GovernancePanel({
|
||||
const [promptError, setPromptError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleApplyBuff = async () => {
|
||||
if (!projectId) return;
|
||||
setBuffLoading(true);
|
||||
setBuffError(null);
|
||||
setBuffInitPrompt(null);
|
||||
setPackTemplates(null);
|
||||
setPackHook(null);
|
||||
try {
|
||||
const report = await applyOnboardingPack(projectPath, projectId);
|
||||
setPackTemplates(report.templates);
|
||||
setPackHook(report.hook);
|
||||
/** 接入面板关闭后:未执行接入不动;执行过则刷新 buff,尚无蓝图时给 init prompt */
|
||||
const afterOnboard = async (report: OnboardReport | null) => {
|
||||
if (!report) return;
|
||||
setOnboardSummary(`${report.status}:${report.summary}`);
|
||||
refetchBuff();
|
||||
if (report.status !== "done" && report.status !== "partial") return;
|
||||
// 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化
|
||||
if (bpStatus?.status === "none") {
|
||||
const p = await generateBlueprintPrompt(projectPath, "init").catch(() => null);
|
||||
if (p) setBuffInitPrompt(p);
|
||||
}
|
||||
try {
|
||||
setBuffInitPrompt(await generateBlueprintPrompt(projectPath, "init"));
|
||||
} catch (e) {
|
||||
setBuffError(String(e));
|
||||
} finally {
|
||||
setBuffLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 「应用接入包」改为一键接入:先 plan 预览、人确认后再 apply(P8g),与新建项目走同一面板
|
||||
const handleApplyBuff = () => {
|
||||
if (!projectId) return;
|
||||
setBuffError(null);
|
||||
setBuffInitPrompt(null);
|
||||
setOnboardSummary(null);
|
||||
openOnboard({ projectId, projectName, onClosed: (report) => void afterOnboard(report) });
|
||||
};
|
||||
|
||||
const handleRemoveBuff = async () => {
|
||||
@ -1098,8 +1086,7 @@ function GovernancePanel({
|
||||
await removeBlueprintBuff(projectPath);
|
||||
refetchBuff();
|
||||
setBuffInitPrompt(null);
|
||||
setPackTemplates(null);
|
||||
setPackHook(null);
|
||||
setOnboardSummary(null);
|
||||
} catch (e) {
|
||||
setBuffError(String(e));
|
||||
} finally {
|
||||
@ -1296,7 +1283,7 @@ function GovernancePanel({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||
应用接入包:种入 AGENTS.md / lefthook / git-workflow 标准模板(不覆盖已有),并挂 buff 自动监听 git 提交、采集飞轮数据。
|
||||
一键接入:先预览将写入的接入包文件(AGENTS.md / lefthook / git-workflow 等)与本机三端配置,确认后再写入,并挂 buff 自动监听 git 提交、采集飞轮数据。
|
||||
</p>
|
||||
{buffStatus?.last_sync_at && (
|
||||
<p className="text-[10px] text-gray-400">上次同步:{buffStatus.last_sync_at}</p>
|
||||
@ -1304,54 +1291,27 @@ function GovernancePanel({
|
||||
{buffError && (
|
||||
<p className="text-[11px] text-red-600 font-mono break-all">{buffError}</p>
|
||||
)}
|
||||
{packTemplates && (
|
||||
<div className="space-y-0.5 pt-0.5">
|
||||
<p className="text-[10px] text-gray-500 font-semibold">模板分发</p>
|
||||
{packTemplates.map((t) => {
|
||||
const style =
|
||||
TEMPLATE_ACTION_STYLE[t.action] ?? TEMPLATE_ACTION_STYLE.failed;
|
||||
return (
|
||||
<div key={t.name}>
|
||||
<p className="text-[10px] font-mono flex items-center gap-1">
|
||||
<span className={style.color}>{style.icon}</span>
|
||||
<span className="text-gray-600 truncate flex-1">{t.name}</span>
|
||||
<span className="text-gray-400">{style.label ?? t.detail}</span>
|
||||
</p>
|
||||
{t.warning && (
|
||||
<p className="text-[10px] text-amber-600 leading-relaxed pl-3">⚠ {t.warning}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{packHook && (
|
||||
<p className="text-[10px] font-mono flex items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
packHook.status === "activated"
|
||||
? "text-green-600"
|
||||
: packHook.status === "missing_binary"
|
||||
? "text-amber-500"
|
||||
: "text-red-500"
|
||||
}
|
||||
>
|
||||
{packHook.status === "activated" ? "✓" : packHook.status === "missing_binary" ? "⚠" : "✗"}
|
||||
</span>
|
||||
<span className="text-gray-600 truncate flex-1">lefthook hook</span>
|
||||
<span className="text-gray-400 truncate" title={packHook.detail}>
|
||||
{packHook.detail}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onboardSummary && (
|
||||
<p className="text-[10px] text-gray-500 break-all">最近一次一键接入:{onboardSummary}</p>
|
||||
)}
|
||||
{buffStatus?.status === "active" ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleApplyBuff}
|
||||
disabled={buffLoading || !projectId}
|
||||
title={!projectId ? "需要传入 projectId" : "重新计划并预览,确认后补齐缺失项"}
|
||||
className="flex-1 py-1.5 rounded-lg border border-purple-200 text-purple-700 text-xs hover:bg-purple-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
重新检查接入
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemoveBuff}
|
||||
disabled={buffLoading}
|
||||
className="w-full py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
|
||||
className="flex-1 py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{buffLoading ? "处理中…" : "撤销 Buff"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleApplyBuff}
|
||||
@ -1359,7 +1319,7 @@ function GovernancePanel({
|
||||
title={!projectId ? "需要传入 projectId" : undefined}
|
||||
className="w-full py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{buffLoading ? "施加中…" : "⚡ 施加 Buff"}
|
||||
⚡ 一键接入(先预览再确认)
|
||||
</button>
|
||||
)}
|
||||
{/* 已有蓝图时施加 Buff:直接激活,无需初始化 */}
|
||||
|
||||
189
src/components/dashboard/OnboardPanel.tsx
Normal file
189
src/components/dashboard/OnboardPanel.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
// 一键接入面板(agent-client-parity P8g):plan_onboarding 预览 → 人确认 → onboard_project → 分步结果。
|
||||
// 全局挂载在 App,由 useUIStore.openOnboard 打开。判定全部来自后端,映射见 onboardView.ts。
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { onboardProject, planOnboarding, type OnboardReport, type OnboardingPlan } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import {
|
||||
DEFAULT_SELECTION,
|
||||
buildPlanView,
|
||||
buildReportView,
|
||||
confirmRequest,
|
||||
selectionOptions,
|
||||
setBypass,
|
||||
toggleSkip,
|
||||
type OnboardSelection,
|
||||
} from "./onboardView";
|
||||
import { PlanPreview, ReportResult } from "./OnboardSections";
|
||||
|
||||
type Phase =
|
||||
| { kind: "planning" }
|
||||
| { kind: "preview"; plan: OnboardingPlan; planned: OnboardSelection }
|
||||
| { kind: "applying"; plan: OnboardingPlan; planned: OnboardSelection }
|
||||
| { kind: "report"; report: OnboardReport }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function OnboardPanel() {
|
||||
const target = useUIStore((s) => s.onboardTarget);
|
||||
if (!target) return null;
|
||||
// key:换项目即重置面板状态
|
||||
return <OnboardPanelBody key={target.projectId} />;
|
||||
}
|
||||
|
||||
function OnboardPanelBody() {
|
||||
const target = useUIStore((s) => s.onboardTarget)!;
|
||||
const closeOnboard = useUIStore((s) => s.closeOnboard);
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "planning" });
|
||||
const [selection, setSelection] = useState<OnboardSelection>(DEFAULT_SELECTION);
|
||||
const lastReport = useRef<OnboardReport | null>(null);
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
const plan = useCallback(
|
||||
async (chosen: OnboardSelection) => {
|
||||
const seq = ++requestSeq.current;
|
||||
setPhase({ kind: "planning" });
|
||||
try {
|
||||
const next = await planOnboarding(target.projectId, selectionOptions(chosen));
|
||||
if (seq === requestSeq.current) setPhase({ kind: "preview", plan: next, planned: chosen });
|
||||
} catch (e) {
|
||||
if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) });
|
||||
}
|
||||
},
|
||||
[target.projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void plan(DEFAULT_SELECTION);
|
||||
}, [plan]);
|
||||
|
||||
const applyConfirmed = async () => {
|
||||
if (phase.kind !== "preview") return;
|
||||
const request = confirmRequest(phase.plan, phase.planned, selection);
|
||||
if (!request) return;
|
||||
const seq = ++requestSeq.current;
|
||||
setPhase({ kind: "applying", plan: phase.plan, planned: phase.planned });
|
||||
try {
|
||||
const report = await onboardProject(request.projectId, request.expectedDigest, request.confirmed, request.options);
|
||||
if (seq !== requestSeq.current) return;
|
||||
lastReport.current = report;
|
||||
setPhase({ kind: "report", report });
|
||||
showToast(buildReportView(report).toast, 4000);
|
||||
void qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
} catch (e) {
|
||||
if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
/** refused 附当前计划:回到预览,按新计划重新确认(选项沿用后端拒绝时的那组) */
|
||||
const reviewReplan = (next: OnboardingPlan) => {
|
||||
if (phase.kind !== "report") return;
|
||||
setPhase({ kind: "preview", plan: next, planned: selection });
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (phase.kind === "applying") return; // 写入进行中不允许关闭,避免误以为已取消
|
||||
requestSeq.current += 1;
|
||||
const report = lastReport.current;
|
||||
closeOnboard();
|
||||
target.onClosed?.(report);
|
||||
};
|
||||
|
||||
const title = `一键接入${target.projectName ? ` · ${target.projectName}` : ""}`;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl mx-4 flex flex-col" style={{ maxHeight: "88vh" }}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-base font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={close}
|
||||
disabled={phase.kind === "applying"}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none disabled:opacity-30"
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-4 text-sm">
|
||||
{phase.kind === "planning" && <p className="text-gray-400">正在生成接入计划(只读,不写任何文件)…</p>}
|
||||
{phase.kind === "error" && (
|
||||
<p className="text-red-600 font-mono text-xs break-all">❌ {phase.message}</p>
|
||||
)}
|
||||
{(phase.kind === "preview" || phase.kind === "applying") && (
|
||||
<PlanPreview
|
||||
view={buildPlanView(phase.plan, phase.planned, selection)}
|
||||
disabled={phase.kind === "applying"}
|
||||
onToggleSkip={(item) => setSelection((s) => toggleSkip(s, item))}
|
||||
onBypass={(on) => setSelection((s) => setBypass(s, on))}
|
||||
/>
|
||||
)}
|
||||
{phase.kind === "report" && (
|
||||
<ReportResult view={buildReportView(phase.report)} onReviewReplan={reviewReplan} />
|
||||
)}
|
||||
</div>
|
||||
<PanelFooter
|
||||
phase={phase}
|
||||
selection={selection}
|
||||
onCancel={close}
|
||||
onReplan={() => void plan(selection)}
|
||||
onConfirm={() => void applyConfirmed()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnCls = "px-4 py-2 rounded-lg text-sm border transition-colors disabled:opacity-50";
|
||||
|
||||
function PanelFooter({
|
||||
phase,
|
||||
selection,
|
||||
onCancel,
|
||||
onReplan,
|
||||
onConfirm,
|
||||
}: {
|
||||
phase: Phase;
|
||||
selection: OnboardSelection;
|
||||
onCancel: () => void;
|
||||
onReplan: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const view = phase.kind === "preview" ? buildPlanView(phase.plan, phase.planned, selection) : null;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100 shrink-0">
|
||||
{view?.confirm.reason && <p className="flex-1 text-xs text-amber-700">{view.confirm.reason}</p>}
|
||||
{phase.kind === "applying" && (
|
||||
<p className="flex-1 text-xs text-gray-500">正在接入:首次可能 30 秒以上,完成前请勿关闭…</p>
|
||||
)}
|
||||
{phase.kind === "report" ? (
|
||||
<button onClick={onCancel} className={`${btnCls} border-gray-200 text-gray-700 hover:bg-gray-50`}>
|
||||
关闭
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={phase.kind === "applying"}
|
||||
className={`${btnCls} border-gray-200 text-gray-600 hover:bg-gray-50`}
|
||||
title={phase.kind === "error" ? "关闭面板;若错误发生在确认接入之后,可能已有部分写入,请重新计划核对" : "取消不写入任何文件"}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{(phase.kind === "error" || (view && (view.needsReplan || view.blockingCount > 0))) && (
|
||||
<button onClick={onReplan} className={`${btnCls} border-blue-200 text-blue-700 hover:bg-blue-50`}>
|
||||
{phase.kind === "error" ? "重试" : "重新计划"}
|
||||
</button>
|
||||
)}
|
||||
{(phase.kind === "preview" || phase.kind === "applying") && (
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={phase.kind === "applying" || !view?.confirm.enabled}
|
||||
className={`${btnCls} border-transparent bg-blue-600 text-white hover:bg-blue-700`}
|
||||
>
|
||||
{phase.kind === "applying" ? "接入中…" : "确认接入"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
src/components/dashboard/OnboardSections.tsx
Normal file
277
src/components/dashboard/OnboardSections.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
// 一键接入面板的呈现块(P8g):计划预览与接入结果。只渲染 onboardView 的视图模型,不做判定。
|
||||
import type { ReactNode } from "react";
|
||||
import type { OnboardBlockedItem, OnboardingPlan } from "../../lib/commands";
|
||||
import { PARITY_CHECKS, CHECK_LABEL, type OnboardTone, type PlanView, type ReportView } from "./onboardView";
|
||||
|
||||
const TONE_CLS: Record<OnboardTone, string> = {
|
||||
ok: "text-green-700 bg-green-50 border-green-200",
|
||||
info: "text-blue-700 bg-blue-50 border-blue-200",
|
||||
muted: "text-gray-500 bg-gray-50 border-gray-200",
|
||||
pending: "text-violet-700 bg-violet-50 border-violet-200",
|
||||
warn: "text-amber-700 bg-amber-50 border-amber-200",
|
||||
danger: "text-red-700 bg-red-50 border-red-200",
|
||||
};
|
||||
|
||||
function ToneBadge({ tone, children }: { tone: OnboardTone; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-block shrink-0 text-[10px] px-1.5 py-0.5 rounded border ${TONE_CLS[tone]}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, extra, children }: { title: string; extra?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-semibold text-gray-500">{title}</p>
|
||||
{extra}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, tone, badge, detail, mono }: { label: ReactNode; tone: OnboardTone; badge: string; detail?: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
<span className={`flex-1 min-w-0 truncate text-gray-700 ${mono ? "font-mono" : ""}`}>{label}</span>
|
||||
{detail && <span className="max-w-[55%] text-gray-400 truncate" title={detail}>{detail}</span>}
|
||||
<ToneBadge tone={tone}>{badge}</ToneBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 计划预览 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlanPreview({
|
||||
view,
|
||||
disabled,
|
||||
onToggleSkip,
|
||||
onBypass,
|
||||
}: {
|
||||
view: PlanView;
|
||||
disabled: boolean;
|
||||
onToggleSkip: (item: OnboardBlockedItem) => void;
|
||||
onBypass: (on: boolean) => void;
|
||||
}) {
|
||||
const { fileCounts } = view;
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">
|
||||
将写入 <span className="font-mono text-gray-700">{view.root}</span>
|
||||
<span className="ml-2 text-gray-300 font-mono" title={view.digest}>digest {view.digest.slice(0, 8)}</span>
|
||||
</p>
|
||||
|
||||
{view.blocked.length > 0 && (
|
||||
<Section title="阻断项" extra={view.blockingCount > 0 && <ToneBadge tone="danger">{view.blockingCount} 项阻断确认</ToneBadge>}>
|
||||
{view.blocked.map((row) => (
|
||||
<div key={`${row.target}:${row.item.reason}`} className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[row.tone]}`}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono flex-1 min-w-0 truncate">{row.target}</span>
|
||||
<span>{row.reasonLabel}</span>
|
||||
<ToneBadge tone={row.tone}>{row.stateLabel}</ToneBadge>
|
||||
{row.toggleLabel && (
|
||||
<button
|
||||
onClick={() => onToggleSkip(row.item)}
|
||||
disabled={disabled}
|
||||
className="px-2 py-0.5 rounded border border-current text-[11px] hover:bg-white/60 disabled:opacity-50"
|
||||
>
|
||||
{row.toggleLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] opacity-80 break-all">{row.detail}</p>
|
||||
<p className="text-[11px] opacity-70">{row.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="需要你留意">
|
||||
<div className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[view.bypass.tone]}`}>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
{view.bypass.toggleable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={view.bypass.selected}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onBypass(e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{view.bypass.label}</span>
|
||||
</label>
|
||||
<p className="text-[11px] opacity-80">{view.bypass.detail}</p>
|
||||
</div>
|
||||
<div className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[view.codexTrust.tone]}`}>
|
||||
<p className="text-xs font-medium">{view.codexTrust.label}</p>
|
||||
{view.codexTrust.detail && <p className="text-[11px] opacity-80 break-all">{view.codexTrust.detail}</p>}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={`项目文件(${view.files.length})`}
|
||||
extra={<span className="text-[11px] text-gray-400">新建 {fileCounts.create} · 更新 {fileCounts.update} · 不变 {fileCounts.noop}</span>}
|
||||
>
|
||||
{view.files.map((file) => (
|
||||
<div key={file.path} className="space-y-0.5">
|
||||
<Row label={file.path} mono tone={file.tone} badge={file.actionLabel} />
|
||||
{file.warning && <p className="text-[11px] text-amber-700 pl-2">⚠ {file.warning}</p>}
|
||||
{file.diff && file.action !== "noop" && (
|
||||
<details className="pl-2">
|
||||
<summary className="text-[11px] text-gray-400 cursor-pointer">查看差异</summary>
|
||||
<pre className="text-[10px] bg-gray-50 border border-gray-100 rounded p-2 overflow-x-auto max-h-48">{file.diff}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="本机配置(不进仓库)">
|
||||
{view.machineSteps.map((step) => (
|
||||
<Row key={step.step} label={step.label} tone={step.tone} badge={step.statusLabel} detail={step.detail} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{view.nextActions.length > 0 && (
|
||||
<Section title="接入后由你完成(不阻断)">
|
||||
{view.nextActions.map((action) => (
|
||||
<Row key={action.action} label={action.label} tone={action.tone} badge="待办" detail={action.detail} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 接入结果 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function L1Matrix({ l1 }: { l1: NonNullable<ReportView["l1"]> }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-gray-400">
|
||||
<th className="text-left font-normal py-1 pr-2">检查项</th>
|
||||
{l1.columns.map((column) => (
|
||||
<th key={column.client} className="text-left font-normal py-1 px-1">
|
||||
{column.label}
|
||||
{column.version && <span className="ml-1 text-gray-300 font-mono">{column.version}</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PARITY_CHECKS.map((check) => (
|
||||
<tr key={check} className="border-t border-gray-50">
|
||||
<td className="py-1 pr-2 text-gray-600">
|
||||
{check} {CHECK_LABEL[check]}
|
||||
</td>
|
||||
{l1.columns.map((column) => {
|
||||
const cell = column.cells[check];
|
||||
return (
|
||||
<td key={column.client} className="py-1 px-1" title={`${cell.evidenceLabel}:${cell.detail}`}>
|
||||
<ToneBadge tone={cell.tone}>{cell.label}</ToneBadge>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{l1.note && <p className="text-[10px] text-gray-400 mt-1">{l1.note}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportResult({
|
||||
view,
|
||||
onReviewReplan,
|
||||
}: {
|
||||
view: ReportView;
|
||||
onReviewReplan: (plan: OnboardingPlan) => void;
|
||||
}) {
|
||||
const { commit } = view;
|
||||
return (
|
||||
<>
|
||||
<div className={`rounded-lg border p-3 space-y-1 ${TONE_CLS[view.tone]}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{view.statusLabel}</span>
|
||||
<span className="text-[11px] opacity-70">用时 {view.durationLabel}</span>
|
||||
</div>
|
||||
<p className="text-xs">{view.summary}</p>
|
||||
{view.reason && <p className="text-xs break-all">原因:{view.reason}</p>}
|
||||
{view.replan && (
|
||||
<button
|
||||
onClick={() => onReviewReplan(view.replan!)}
|
||||
className="mt-1 px-3 py-1 rounded border border-current text-xs hover:bg-white/60"
|
||||
>
|
||||
查看当前计划并重新确认
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view.steps.length > 0 && (
|
||||
<Section title="分步结果">
|
||||
{view.steps.map((step) => (
|
||||
<Row
|
||||
key={step.step}
|
||||
label={step.note ? `${step.label}(${step.note})` : step.label}
|
||||
tone={step.tone}
|
||||
badge={step.statusLabel}
|
||||
detail={step.detail}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{view.l1 && (
|
||||
<Section title="三端检查(L1 零模型)" extra={view.l1.allApplicableReady && <ToneBadge tone="ok">适用格全部就绪</ToneBadge>}>
|
||||
<L1Matrix l1={view.l1} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{commit.state === "available" && (
|
||||
<Section title={commit.title}>
|
||||
{commit.files.map((file) => (
|
||||
<p key={file.path} className="text-xs font-mono text-gray-700">
|
||||
<span className="text-gray-400 mr-2 whitespace-pre">{file.code}</span>
|
||||
{file.path}
|
||||
</p>
|
||||
))}
|
||||
{commit.otherWarning && <p className="text-[11px] text-amber-700">⚠ {commit.otherWarning}</p>}
|
||||
{commit.suggestedMessage && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
建议提交信息:<span className="font-mono text-gray-700">{commit.suggestedMessage}</span>
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{commit.state === "unavailable" && <p className="text-[11px] text-gray-400">无法列出改动:{commit.reason}</p>}
|
||||
{view.head && <p className={`text-[11px] ${view.head.unchanged ? "text-gray-400" : "text-amber-700"}`}>{view.head.label}</p>}
|
||||
|
||||
{(view.pendingUser.length > 0 || view.nextActions.length > 0) && (
|
||||
<Section title="待你操作">
|
||||
{view.pendingUser.map((row) => (
|
||||
<Row key={row.item} label={row.item} tone={row.tone} badge={row.label} detail={row.detail} />
|
||||
))}
|
||||
{view.nextActions.map((action) => (
|
||||
<Row key={action.action} label={action.label} tone={action.tone} badge="待办" detail={action.detail} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{view.completion.length > 0 && (
|
||||
<details>
|
||||
<summary className="text-xs text-gray-500 cursor-pointer">
|
||||
完成判定明细({view.completion.filter((row) => row.ready).length}/{view.completion.length} 就绪)
|
||||
</summary>
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{view.completion.map((row) => (
|
||||
<Row key={`${row.source}:${row.item}`} label={row.item} tone={row.tone} badge={row.label} detail={row.detail} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400">{view.l3}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -19,6 +19,7 @@ export function ProjectModal() {
|
||||
const editTarget = useUIStore((s) => s.editTarget);
|
||||
const closeEdit = useUIStore((s) => s.closeEdit);
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
|
||||
@ -93,6 +94,12 @@ const pickFolder = async (field: "wsl_path" | "win_path") => {
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
qc.invalidateQueries({ queryKey: ["tagMaps"] });
|
||||
closeEdit();
|
||||
// create_project 本身无副作用;选了目录的新项目弹出接入预览(P8g),由人确认后才写入。不选目录的概念占位不接入
|
||||
// 与后端登记同口径:按 platform 只看有效根目录那一列(另一列残留不算)
|
||||
const effectiveRoot = form.platform === "wsl" ? form.wsl_path : form.win_path;
|
||||
if (isNew && effectiveRoot?.trim()) {
|
||||
openOnboard({ projectId: form.id!, projectName: form.name?.trim() });
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
|
||||
290
src/components/dashboard/onboardView.test.ts
Normal file
290
src/components/dashboard/onboardView.test.ts
Normal file
@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ClientParityL1Report,
|
||||
OnboardBlockedItem,
|
||||
OnboardReport,
|
||||
OnboardingPlan,
|
||||
ParityCell,
|
||||
ParityCheck,
|
||||
} from "../../lib/commands";
|
||||
import {
|
||||
DEFAULT_SELECTION,
|
||||
buildPlanView,
|
||||
buildReportView,
|
||||
confirmRequest,
|
||||
l1MatrixView,
|
||||
selectionOptions,
|
||||
setBypass,
|
||||
toggleSkip,
|
||||
type OnboardSelection,
|
||||
} from "./onboardView";
|
||||
|
||||
const manualCodex: OnboardBlockedItem = {
|
||||
target: ".codex/config.toml",
|
||||
reason: "manual_entry",
|
||||
detail: "已有手写的 lian-jing 条目",
|
||||
skippable: true,
|
||||
};
|
||||
|
||||
const mixedAgents: OnboardBlockedItem = {
|
||||
target: "AGENTS.md",
|
||||
reason: "mixed_file",
|
||||
detail: "托管块外有未提交改动",
|
||||
skippable: false,
|
||||
};
|
||||
|
||||
function plan(overrides: Partial<OnboardingPlan> = {}): OnboardingPlan {
|
||||
return {
|
||||
project_id: "p1",
|
||||
root: "E:\\code-project\\demo",
|
||||
files: [
|
||||
{ path: "AGENTS.md", action: "create", content_sha256: "a" },
|
||||
{ path: ".claude/settings.json", action: "update", content_sha256: "b", before_sha256: "c" },
|
||||
{ path: ".lian-jing/context.json", action: "noop", content_sha256: "d" },
|
||||
],
|
||||
machine_changes: [
|
||||
{ step: "token", status: "apply", detail: "将生成项目 token" },
|
||||
{ step: "claude", status: "noop", detail: "已配置" },
|
||||
{ step: "codex", status: "not_installed", detail: "未找到 codex" },
|
||||
{ step: "dsh", status: "not_applicable", detail: "控制面项目" },
|
||||
],
|
||||
blocked: [],
|
||||
skipped: [],
|
||||
bypass_permissions: true,
|
||||
codex_trust: "untrusted",
|
||||
next_actions: [{ action: "codex_trust", detail: "在 Codex 里信任 E:\\code-project\\demo" }],
|
||||
digest: "digest-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const sel = (overrides: Partial<OnboardSelection> = {}): OnboardSelection => ({ ...DEFAULT_SELECTION, ...overrides });
|
||||
|
||||
describe("buildPlanView 阻断与确认", () => {
|
||||
it("blocked 非空时确认禁用,并逐项列出文件与原因", () => {
|
||||
const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel());
|
||||
expect(view.confirm.enabled).toBe(false);
|
||||
expect(view.confirm.reason).toContain("2 项阻断");
|
||||
expect(view.blockingCount).toBe(2);
|
||||
expect(view.blocked.map((row) => [row.target, row.reasonLabel, row.detail, row.state])).toEqual([
|
||||
["AGENTS.md", "混合文件:托管块外有未提交改动", "托管块外有未提交改动", "blocking"],
|
||||
[".codex/config.toml", "手写同名条目", "已有手写的 lian-jing 条目", "blocking"],
|
||||
]);
|
||||
expect(view.blocked.every((row) => row.tone === "danger")).toBe(true);
|
||||
expect(confirmRequest(plan({ blocked: [mixedAgents] }), sel(), sel())).toBeNull();
|
||||
});
|
||||
|
||||
it("只有后端标 skippable 的项给「跳过」,不可跳过的项切换无效", () => {
|
||||
const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel());
|
||||
expect(view.blocked[0].toggleLabel).toBeUndefined();
|
||||
expect(view.blocked[0].hint).toContain("禁止字符串手术");
|
||||
expect(view.blocked[1].toggleLabel).toBe("跳过");
|
||||
expect(toggleSkip(sel(), mixedAgents)).toEqual(sel());
|
||||
expect(toggleSkip(sel(), manualCodex).skipTargets).toEqual([".codex/config.toml"]);
|
||||
expect(toggleSkip(sel({ skipTargets: [".codex/config.toml"] }), manualCodex).skipTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it("选了跳过但未重新计划时确认禁用;按跳过重新计划后可确认", () => {
|
||||
const chosen = toggleSkip(sel(), manualCodex);
|
||||
const pending = buildPlanView(plan({ blocked: [manualCodex] }), sel(), chosen);
|
||||
expect(pending.needsReplan).toBe(true);
|
||||
expect(pending.blocked[0].state).toBe("skip_pending");
|
||||
expect(pending.confirm).toEqual({ enabled: false, reason: "选项已改:须按当前选择重新计划后才能确认" });
|
||||
|
||||
const replanned = buildPlanView(plan({ blocked: [], skipped: [manualCodex] }), chosen, chosen);
|
||||
expect(replanned.confirm.enabled).toBe(true);
|
||||
expect(replanned.blocked[0]).toMatchObject({ state: "skipped", tone: "muted", toggleLabel: "取消跳过" });
|
||||
expect(replanned.blockingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("确认请求携带计划 digest 与生成计划时的选项", () => {
|
||||
const planned = sel({ skipTargets: [".codex/config.toml", ".codex/config.toml"] });
|
||||
const request = confirmRequest(plan({ skipped: [manualCodex], digest: "digest-xyz" }), planned, planned);
|
||||
expect(request).toEqual({
|
||||
projectId: "p1",
|
||||
expectedDigest: "digest-xyz",
|
||||
confirmed: true,
|
||||
options: { bypass_permissions: true, skip_targets: [".codex/config.toml"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("selectionOptions 去重排序,计划与应用两次逐字一致", () => {
|
||||
expect(selectionOptions(sel({ skipTargets: ["b", "a", "b"] }))).toEqual({
|
||||
bypass_permissions: true,
|
||||
skip_targets: ["a", "b"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPlanView 灰色说明与单列项", () => {
|
||||
it("not_installed / not_applicable 显示为灰色说明,不是红色失败", () => {
|
||||
const view = buildPlanView(plan(), sel(), sel());
|
||||
const byStep = Object.fromEntries(view.machineSteps.map((row) => [row.step, row]));
|
||||
expect(byStep.codex).toMatchObject({ statusLabel: "未安装", tone: "muted" });
|
||||
expect(byStep.dsh).toMatchObject({ statusLabel: "不适用", tone: "muted" });
|
||||
expect(byStep.token).toMatchObject({ label: "项目 MCP token", statusLabel: "将写入", tone: "info" });
|
||||
expect(view.machineSteps.some((row) => row.tone === "danger")).toBe(false);
|
||||
expect(view.fileCounts).toEqual({ create: 1, update: 1, noop: 1 });
|
||||
});
|
||||
|
||||
it("bypassPermissions 单独列出:将写入可取消,取消后须重新计划", () => {
|
||||
const view = buildPlanView(plan(), sel(), sel());
|
||||
expect(view.bypass).toMatchObject({ willWrite: true, toggleable: true, tone: "warn" });
|
||||
expect(view.bypass.label).toContain("bypassPermissions");
|
||||
|
||||
const off = setBypass(sel(), false);
|
||||
expect(buildPlanView(plan(), sel(), off).confirm.enabled).toBe(false);
|
||||
const cancelled = buildPlanView(plan({ bypass_permissions: false }), off, off);
|
||||
expect(cancelled.bypass).toMatchObject({ willWrite: false, selected: false, toggleable: true });
|
||||
expect(cancelled.bypass.label).toBe("已按项目取消 bypassPermissions");
|
||||
expect(confirmRequest(plan({ bypass_permissions: false }), off, off)?.options.bypass_permissions).toBe(false);
|
||||
|
||||
const existing = buildPlanView(plan({ bypass_permissions: false }), sel(), sel());
|
||||
expect(existing.bypass).toMatchObject({ toggleable: false, tone: "muted" });
|
||||
});
|
||||
|
||||
it("Codex 信任单独列出:未信任为「待你操作」而非阻断", () => {
|
||||
const view = buildPlanView(plan(), sel(), sel());
|
||||
expect(view.codexTrust).toMatchObject({ state: "untrusted", tone: "pending" });
|
||||
expect(view.codexTrust.detail).toContain("信任");
|
||||
expect(view.confirm.enabled).toBe(true);
|
||||
expect(buildPlanView(plan({ codex_trust: "not_installed", next_actions: [] }), sel(), sel()).codexTrust.tone).toBe("muted");
|
||||
});
|
||||
});
|
||||
|
||||
function cell(state: ParityCell["state"], extra: Partial<ParityCell> = {}): ParityCell {
|
||||
return { state, evidence: "protocol", detail: `${state} detail`, ...extra };
|
||||
}
|
||||
|
||||
function cells(fill: (check: ParityCheck) => ParityCell): Record<ParityCheck, ParityCell> {
|
||||
return Object.fromEntries((["A", "B", "C", "D", "E", "F"] as ParityCheck[]).map((c) => [c, fill(c)])) as Record<
|
||||
ParityCheck,
|
||||
ParityCell
|
||||
>;
|
||||
}
|
||||
|
||||
const l1: ClientParityL1Report = {
|
||||
project_id: "p1",
|
||||
tier: "l1",
|
||||
probe_spec_version: 1,
|
||||
profile: "project",
|
||||
expected_route: "http://127.0.0.1:27190/mcp/projects/p1",
|
||||
expected_tools: 12,
|
||||
nonce: "n",
|
||||
clients: [
|
||||
{ client: "claude", version: "2.1.90", route: null, cells: cells(() => cell("ready")), duration_ms: 1 },
|
||||
{
|
||||
client: "codex",
|
||||
version: "0.50",
|
||||
route: null,
|
||||
cells: cells((c) => (c < "C" ? cell("ready") : cell("fail", { cause: "codex_project_untrusted" }))),
|
||||
duration_ms: 1,
|
||||
},
|
||||
{ client: "dsh", version: null, route: null, cells: cells(() => cell("not_installed")), duration_ms: 1 },
|
||||
],
|
||||
all_applicable_ready: false,
|
||||
duration_ms: 3,
|
||||
note: "",
|
||||
};
|
||||
|
||||
function report(overrides: Partial<OnboardReport> = {}): OnboardReport {
|
||||
return {
|
||||
project_id: "p1",
|
||||
status: "done",
|
||||
summary: "9 步完成",
|
||||
bypass_permissions: true,
|
||||
steps: [
|
||||
{ step: "project_files", status: "applied", detail: "写入 14 个文件" },
|
||||
{ step: "dsh", status: "not_applicable", reason: "not_installed", detail: "未安装 DSH" },
|
||||
{ step: "codex", status: "blocked", dep: "token", detail: "token 未就绪" },
|
||||
],
|
||||
completion: [
|
||||
{ item: "Claude MCP 已配置", source: "clients", ready: true, detail: "", pending_user: false },
|
||||
{ item: "Codex 信任", source: "l1", ready: false, detail: "待信任", pending_user: true },
|
||||
],
|
||||
l1,
|
||||
l3: "L3 尚未提供",
|
||||
written_files: ["AGENTS.md"],
|
||||
repo_changes: {
|
||||
state: "available",
|
||||
status_porcelain: [],
|
||||
diff_stat: "2 files changed",
|
||||
onboard_files: [
|
||||
{ path: "AGENTS.md", code: "??" },
|
||||
{ path: ".claude/settings.json", code: " M" },
|
||||
],
|
||||
other_changes: 1,
|
||||
},
|
||||
head: { before: "abc", after: "abc", unchanged: true },
|
||||
suggested_commit_message: "chore(lian-jing): 一键接入",
|
||||
next_actions: [{ action: "commit", detail: "git add -- AGENTS.md" }],
|
||||
duration_ms: 7900,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReportView", () => {
|
||||
it("分步结果:not_applicable 灰色、blocked 注明依赖", () => {
|
||||
const view = buildReportView(report());
|
||||
expect(view.steps.map((row) => [row.step, row.statusLabel, row.tone, row.note])).toEqual([
|
||||
["project_files", "已写入", "ok", undefined],
|
||||
["dsh", "不适用", "muted", "未安装"],
|
||||
["codex", "未执行", "warn", "依赖「项目 MCP token」未完成"],
|
||||
]);
|
||||
expect(view.toast).toBe("一键接入接入完成 ✓");
|
||||
expect(view.durationLabel).toBe("7.9 秒");
|
||||
});
|
||||
|
||||
it("待你提交的 N 个文件与 Codex 信任单列,不计入完成", () => {
|
||||
const view = buildReportView(report());
|
||||
expect(view.commit).toMatchObject({ state: "available", title: "待你提交的 2 个文件", otherChanges: 1 });
|
||||
expect(view.commit.state === "available" && view.commit.otherWarning).toContain("不要 git add -A");
|
||||
expect(view.completion.map((row) => row.item)).toEqual(["Claude MCP 已配置"]);
|
||||
expect(view.pendingUser).toEqual([
|
||||
{ item: "Codex 信任", source: "l1", ready: false, label: "待你操作", tone: "pending", detail: "待信任" },
|
||||
]);
|
||||
expect(view.head).toMatchObject({ unchanged: true, tone: "ok" });
|
||||
});
|
||||
|
||||
it("refused 附当前计划供重新确认,提示以 ❌ 开头且不报告改动", () => {
|
||||
const replan = plan({ digest: "digest-2" });
|
||||
const view = buildReportView(
|
||||
report({ status: "refused", reason: "digest 不符", plan: replan, repo_changes: undefined, l1: undefined }),
|
||||
);
|
||||
expect(view.replan).toBe(replan);
|
||||
expect(view.tone).toBe("warn");
|
||||
expect(view.toast).toBe("❌ 一键接入已拒绝(零写入):digest 不符");
|
||||
expect(view.commit).toEqual({ state: "none" });
|
||||
expect(view.l1).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("l1MatrixView", () => {
|
||||
it("未安装为灰色,Codex 未信任为「待你信任」而非红色失败", () => {
|
||||
const view = l1MatrixView(l1);
|
||||
const [claude, codex, dsh] = view.columns;
|
||||
expect(claude.cells.F).toMatchObject({ label: "就绪", tone: "ok" });
|
||||
expect(codex.cells.A.tone).toBe("ok");
|
||||
expect(codex.cells.C).toMatchObject({ label: "待你信任", tone: "pending", cause: "codex_project_untrusted" });
|
||||
expect(dsh.cells.A).toMatchObject({ label: "未安装", tone: "muted" });
|
||||
expect(Object.values(dsh.cells).every((c) => c.tone === "muted")).toBe(true);
|
||||
expect(dsh.label).toBe("DSH");
|
||||
});
|
||||
|
||||
it("依赖步骤未完成的格注明步骤;真实失败为红色", () => {
|
||||
const report = {
|
||||
...l1,
|
||||
clients: [
|
||||
{
|
||||
...l1.clients[0],
|
||||
cells: cells((c) =>
|
||||
c === "A" ? cell("blocked", { cause: "dependency", dep: "claude" }) : cell("fail", { cause: "auth_failed" }),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
const [claude] = l1MatrixView(report).columns;
|
||||
expect(claude.cells.A).toMatchObject({ label: "未检查:Claude Code 步骤未完成", tone: "warn" });
|
||||
expect(claude.cells.F).toMatchObject({ label: "失败", tone: "danger" });
|
||||
});
|
||||
});
|
||||
672
src/components/dashboard/onboardView.ts
Normal file
672
src/components/dashboard/onboardView.ts
Normal file
@ -0,0 +1,672 @@
|
||||
// 一键接入(agent-client-parity P8g)视图模型:把 OnboardingPlan / OnboardReport 转成界面要展示的结构。
|
||||
// 只做呈现映射,不复制本体判定(AGENTS.md「边界单点实现」):阻断与否看 plan.blocked、能否跳过看
|
||||
// blocked[].skippable、完成与否看 report.status 与 completion[].pending_user,全部由后端给出。
|
||||
// 界面自己只管一件事:当前选择(bypass / 跳过项)与生成计划时所用的选择是否一致——不一致必须重新计划,
|
||||
// 因为 onboardProject 须带同一组选项与 digest(否则后端 refused)。
|
||||
|
||||
import type {
|
||||
ClientParityL1Report,
|
||||
CodexTrust,
|
||||
OnboardBlockReason,
|
||||
OnboardBlockedItem,
|
||||
OnboardCompletionItem,
|
||||
OnboardFileAction,
|
||||
OnboardNextAction,
|
||||
OnboardOptions,
|
||||
OnboardPendingFile,
|
||||
OnboardReport,
|
||||
OnboardStatus,
|
||||
OnboardStepReport,
|
||||
OnboardingPlan,
|
||||
ParityCause,
|
||||
ParityCell,
|
||||
ParityCellState,
|
||||
ParityCheck,
|
||||
ParityEvidence,
|
||||
} from "../../lib/commands";
|
||||
|
||||
/** 呈现色调。muted 是灰色说明(未安装 / 不适用 / 已跳过 / 不变),不是失败;pending 是「待你操作」 */
|
||||
export type OnboardTone = "ok" | "info" | "muted" | "pending" | "warn" | "danger";
|
||||
|
||||
// ── 选项:计划与应用必须一致 ──────────────────────────────────────────────────
|
||||
|
||||
export interface OnboardSelection {
|
||||
/** 勾选 = 保持默认:项目 .claude/settings.json 还没有 permissions 时写入 bypassPermissions */
|
||||
bypassPermissions: boolean;
|
||||
/** 人逐项选「跳过」的阻断项 target(只对 skippable 的项生效) */
|
||||
skipTargets: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_SELECTION: OnboardSelection = { bypassPermissions: true, skipTargets: [] };
|
||||
|
||||
function normalizeTargets(targets: readonly string[]): string[] {
|
||||
return [...new Set(targets)].sort();
|
||||
}
|
||||
|
||||
/** 选择 → 传给 planOnboarding / onboardProject 的选项(跳过项去重排序,两次调用逐字一致) */
|
||||
export function selectionOptions(selection: OnboardSelection): OnboardOptions {
|
||||
return {
|
||||
bypass_permissions: selection.bypassPermissions,
|
||||
skip_targets: normalizeTargets(selection.skipTargets),
|
||||
};
|
||||
}
|
||||
|
||||
export function sameSelection(a: OnboardSelection, b: OnboardSelection): boolean {
|
||||
if (a.bypassPermissions !== b.bypassPermissions) return false;
|
||||
const x = normalizeTargets(a.skipTargets);
|
||||
const y = normalizeTargets(b.skipTargets);
|
||||
return x.length === y.length && x.every((target, i) => target === y[i]);
|
||||
}
|
||||
|
||||
/** 切换某阻断项的「跳过」。后端没标 skippable 的项不响应(只能中止或处理后重新计划) */
|
||||
export function toggleSkip(selection: OnboardSelection, item: OnboardBlockedItem): OnboardSelection {
|
||||
if (!item.skippable) return selection;
|
||||
const has = selection.skipTargets.includes(item.target);
|
||||
return {
|
||||
...selection,
|
||||
skipTargets: has
|
||||
? selection.skipTargets.filter((target) => target !== item.target)
|
||||
: [...selection.skipTargets, item.target],
|
||||
};
|
||||
}
|
||||
|
||||
export function setBypass(selection: OnboardSelection, bypassPermissions: boolean): OnboardSelection {
|
||||
return { ...selection, bypassPermissions };
|
||||
}
|
||||
|
||||
// ── 文案映射(只翻译后端给的枚举,不推断) ─────────────────────────────────────
|
||||
|
||||
interface Badge {
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
}
|
||||
|
||||
const FILE_ACTION: Record<OnboardFileAction, Badge> = {
|
||||
create: { label: "新建", tone: "info" },
|
||||
update: { label: "更新", tone: "info" },
|
||||
noop: { label: "不变", tone: "muted" },
|
||||
};
|
||||
|
||||
const BLOCK_REASON_LABEL: Record<OnboardBlockReason, string> = {
|
||||
mixed_file: "混合文件:托管块外有未提交改动",
|
||||
identity_conflict: "身份冲突:目录已属于别的项目",
|
||||
manual_entry: "手写同名条目",
|
||||
shared_key: "共享项目键:生效条目属于别的项目",
|
||||
needs_human: "需人工处理",
|
||||
};
|
||||
|
||||
/** 不可跳过的项怎么办(可跳过的项统一给「跳过 / 中止」) */
|
||||
const BLOCK_REASON_HINT: Record<OnboardBlockReason, string> = {
|
||||
mixed_file: "先在子项目提交或丢弃块外改动,再重新计划(禁止字符串手术);或中止",
|
||||
identity_conflict: "核对 .lian-jing/context.json 的归属后重新计划;或中止",
|
||||
manual_entry: "处理后重新计划;或中止",
|
||||
shared_key: "处理后重新计划;或中止",
|
||||
needs_human: "按说明处理后重新计划;或中止",
|
||||
};
|
||||
|
||||
export const STEP_LABEL: Record<string, string> = {
|
||||
identity: "项目身份",
|
||||
project_files: "项目文件",
|
||||
git_hooks: "Git hooks(lefthook install)",
|
||||
product_group_buff: "产品组与 buff",
|
||||
token: "项目 MCP token",
|
||||
shim: "稳定 helper 入口",
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
dsh: "DSH",
|
||||
};
|
||||
|
||||
/** 计划里本机七步的预计动作 */
|
||||
const MACHINE_STATUS: Record<string, Badge> = {
|
||||
noop: { label: "已就绪", tone: "ok" },
|
||||
apply: { label: "将写入", tone: "info" },
|
||||
not_installed: { label: "未安装", tone: "muted" },
|
||||
not_applicable: { label: "不适用", tone: "muted" },
|
||||
unavailable: { label: "无法判定", tone: "warn" },
|
||||
manual_kept: { label: "手写同名条目", tone: "warn" },
|
||||
other_project: { label: "属于别的项目", tone: "warn" },
|
||||
needs_human: { label: "需人工处理", tone: "danger" },
|
||||
};
|
||||
|
||||
const CODEX_TRUST: Record<CodexTrust, Badge> = {
|
||||
trusted: { label: "Codex 已信任该目录", tone: "ok" },
|
||||
untrusted: { label: "待你操作:在 Codex 里信任该目录", tone: "pending" },
|
||||
not_installed: { label: "未安装 Codex", tone: "muted" },
|
||||
unknown: { label: "待你确认:无法判断 Codex 是否信任该目录", tone: "pending" },
|
||||
};
|
||||
|
||||
export const NEXT_ACTION_LABEL: Record<string, string> = {
|
||||
codex_trust: "在 Codex 里信任该目录",
|
||||
commit: "提交接入文件",
|
||||
gitea_repo: "建 Gitea 仓库",
|
||||
ci: "配置 CI",
|
||||
flywheel: "飞轮数据",
|
||||
toolchain: "准备工具链",
|
||||
delivery_profile: "交付配置",
|
||||
lefthook: "安装 lefthook",
|
||||
restart_dsh_gui: "重启 DSH 桌面 GUI",
|
||||
claude_trust: "Claude 首次打开目录时确认信任",
|
||||
reopen_sessions: "重开已打开的会话",
|
||||
repo_layout: "仓库布局说明",
|
||||
};
|
||||
|
||||
// ── 计划预览 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FileRow {
|
||||
path: string;
|
||||
action: OnboardFileAction;
|
||||
actionLabel: string;
|
||||
tone: OnboardTone;
|
||||
diff?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface MachineStepRow {
|
||||
step: string;
|
||||
label: string;
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
tone: OnboardTone;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** blocking 阻断中;skip_pending 已选跳过、待重新计划;skipped 计划已按跳过生成;unskip_pending 已取消跳过、待重新计划 */
|
||||
export type BlockedRowState = "blocking" | "skip_pending" | "skipped" | "unskip_pending";
|
||||
|
||||
export interface BlockedRow {
|
||||
item: OnboardBlockedItem;
|
||||
target: string;
|
||||
reasonLabel: string;
|
||||
detail: string;
|
||||
/** 来自后端 skippable:为 true 才给「跳过」按钮 */
|
||||
skippable: boolean;
|
||||
state: BlockedRowState;
|
||||
stateLabel: string;
|
||||
tone: OnboardTone;
|
||||
hint: string;
|
||||
/** 可跳过的项的按钮文案;不可跳过为空(只能中止或处理后重新计划) */
|
||||
toggleLabel?: string;
|
||||
}
|
||||
|
||||
export interface BypassView {
|
||||
/** 本次计划会新写入 permissions.defaultMode=bypassPermissions(后端 plan.bypass_permissions) */
|
||||
willWrite: boolean;
|
||||
/** 当前选择:勾选 = 保持默认写入 */
|
||||
selected: boolean;
|
||||
/** 生成本计划时的选择 */
|
||||
planned: boolean;
|
||||
label: string;
|
||||
detail: string;
|
||||
tone: OnboardTone;
|
||||
/** 会写入或已按项目取消时给开关;项目已有 permissions 时开关无意义,不给 */
|
||||
toggleable: boolean;
|
||||
}
|
||||
|
||||
export interface CodexTrustView {
|
||||
state: CodexTrust;
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface NextActionRow {
|
||||
action: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
tone: OnboardTone;
|
||||
}
|
||||
|
||||
export interface PlanView {
|
||||
projectId: string;
|
||||
root: string;
|
||||
digest: string;
|
||||
files: FileRow[];
|
||||
fileCounts: Record<OnboardFileAction, number>;
|
||||
machineSteps: MachineStepRow[];
|
||||
/** plan.blocked 与 plan.skipped 合并展示 */
|
||||
blocked: BlockedRow[];
|
||||
/** 仍阻断确认的项数(= plan.blocked.length) */
|
||||
blockingCount: number;
|
||||
bypass: BypassView;
|
||||
codexTrust: CodexTrustView;
|
||||
nextActions: NextActionRow[];
|
||||
/** 当前选择与生成计划时不同:必须重新计划才能确认 */
|
||||
needsReplan: boolean;
|
||||
confirm: { enabled: boolean; reason?: string };
|
||||
}
|
||||
|
||||
const BLOCKED_STATE: Record<BlockedRowState, Badge> = {
|
||||
blocking: { label: "阻断", tone: "danger" },
|
||||
skip_pending: { label: "将跳过(需重新计划)", tone: "warn" },
|
||||
skipped: { label: "已跳过:保留现状,该端不写", tone: "muted" },
|
||||
unskip_pending: { label: "将恢复阻断(需重新计划)", tone: "warn" },
|
||||
};
|
||||
|
||||
function blockedRow(item: OnboardBlockedItem, inPlanSkipped: boolean, selected: boolean): BlockedRow {
|
||||
const skippable = item.skippable === true;
|
||||
const state: BlockedRowState = inPlanSkipped
|
||||
? selected ? "skipped" : "unskip_pending"
|
||||
: skippable && selected ? "skip_pending" : "blocking";
|
||||
const badge = BLOCKED_STATE[state];
|
||||
return {
|
||||
item,
|
||||
target: item.target,
|
||||
reasonLabel: BLOCK_REASON_LABEL[item.reason] ?? item.reason,
|
||||
detail: item.detail,
|
||||
skippable,
|
||||
state,
|
||||
stateLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
hint: skippable
|
||||
? "可逐项「跳过」:保留现状、该端不写(跳过不等于该端可用);或中止"
|
||||
: BLOCK_REASON_HINT[item.reason] ?? "处理后重新计划;或中止",
|
||||
toggleLabel: skippable ? (selected ? "取消跳过" : "跳过") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function bypassView(plan: OnboardingPlan, planned: OnboardSelection, selection: OnboardSelection): BypassView {
|
||||
const base = { willWrite: plan.bypass_permissions, selected: selection.bypassPermissions, planned: planned.bypassPermissions };
|
||||
if (plan.bypass_permissions) {
|
||||
return {
|
||||
...base,
|
||||
label: "将写入 permissions.defaultMode=bypassPermissions",
|
||||
detail: "项目 .claude/settings.json 还没有 permissions:按炼境默认写入,Claude Code 在本项目跳过权限确认。可按项目取消(取消后须重新计划)。",
|
||||
tone: "warn",
|
||||
toggleable: true,
|
||||
};
|
||||
}
|
||||
if (!planned.bypassPermissions) {
|
||||
return {
|
||||
...base,
|
||||
label: "已按项目取消 bypassPermissions",
|
||||
detail:
|
||||
"不写入 bypassPermissions。settings.json 缺 permissions 时会写显式 defaultMode=default 记住选择(之后的重注入不会补回);已有 permissions 则原样不动。",
|
||||
tone: "muted",
|
||||
toggleable: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
label: "不新写入 bypassPermissions",
|
||||
detail: "项目 .claude/settings.json 已有 permissions 设置,本次不改。",
|
||||
tone: "muted",
|
||||
toggleable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function nextActionRow(action: OnboardNextAction): NextActionRow {
|
||||
return {
|
||||
action: action.action,
|
||||
label: NEXT_ACTION_LABEL[action.action] ?? action.action,
|
||||
detail: action.detail,
|
||||
tone: action.action === "codex_trust" ? "pending" : "info",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划 → 预览视图。planned 是生成本计划时的选择,selection 是界面当前的选择。
|
||||
* 确认按钮:plan.blocked 非空一律禁用(后端也会 refused);选择改过未重新计划也禁用。
|
||||
*/
|
||||
export function buildPlanView(
|
||||
plan: OnboardingPlan,
|
||||
planned: OnboardSelection,
|
||||
selection: OnboardSelection,
|
||||
): PlanView {
|
||||
const selected = new Set(selection.skipTargets);
|
||||
const fileCounts: Record<OnboardFileAction, number> = { create: 0, update: 0, noop: 0 };
|
||||
const files = plan.files.map((file): FileRow => {
|
||||
fileCounts[file.action] = (fileCounts[file.action] ?? 0) + 1;
|
||||
const badge = FILE_ACTION[file.action] ?? { label: file.action, tone: "warn" };
|
||||
return {
|
||||
path: file.path,
|
||||
action: file.action,
|
||||
actionLabel: badge.label,
|
||||
tone: file.warning ? "warn" : badge.tone,
|
||||
diff: file.diff,
|
||||
warning: file.warning,
|
||||
};
|
||||
});
|
||||
const machineSteps = plan.machine_changes.map((step): MachineStepRow => {
|
||||
const badge = MACHINE_STATUS[step.status] ?? { label: step.status, tone: "warn" };
|
||||
return {
|
||||
step: step.step,
|
||||
label: STEP_LABEL[step.step] ?? step.step,
|
||||
status: step.status,
|
||||
statusLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
detail: step.detail,
|
||||
};
|
||||
});
|
||||
const blocked = [
|
||||
...plan.blocked.map((item) => blockedRow(item, false, selected.has(item.target))),
|
||||
...(plan.skipped ?? []).map((item) => blockedRow(item, true, selected.has(item.target))),
|
||||
];
|
||||
const trust = CODEX_TRUST[plan.codex_trust] ?? { label: plan.codex_trust, tone: "warn" };
|
||||
const trustAction = plan.next_actions.find((action) => action.action === "codex_trust");
|
||||
const needsReplan = !sameSelection(planned, selection);
|
||||
const blockingCount = plan.blocked.length;
|
||||
const reason = needsReplan
|
||||
? "选项已改:须按当前选择重新计划后才能确认"
|
||||
: blockingCount > 0
|
||||
? `有 ${blockingCount} 项阻断,确认已禁用:可跳过的项选「跳过」后重新计划,其余处理后重新计划或中止`
|
||||
: undefined;
|
||||
return {
|
||||
projectId: plan.project_id,
|
||||
root: plan.root,
|
||||
digest: plan.digest,
|
||||
files,
|
||||
fileCounts,
|
||||
machineSteps,
|
||||
blocked,
|
||||
blockingCount,
|
||||
bypass: bypassView(plan, planned, selection),
|
||||
codexTrust: {
|
||||
state: plan.codex_trust,
|
||||
label: trust.label,
|
||||
tone: trust.tone,
|
||||
detail: trustAction?.detail ?? (plan.codex_trust === "trusted" ? "Codex 会加载项目 .codex/config.toml" : ""),
|
||||
},
|
||||
nextActions: plan.next_actions.map(nextActionRow),
|
||||
needsReplan,
|
||||
confirm: { enabled: reason === undefined, reason },
|
||||
};
|
||||
}
|
||||
|
||||
export interface OnboardConfirmRequest {
|
||||
projectId: string;
|
||||
/** 用户确认过的计划 digest */
|
||||
expectedDigest: string;
|
||||
confirmed: true;
|
||||
/** 生成该计划时的选项(不是界面当前选择) */
|
||||
options: OnboardOptions;
|
||||
}
|
||||
|
||||
/** 确认接入的调用参数;确认按钮应禁用时返回 null(不发请求) */
|
||||
export function confirmRequest(
|
||||
plan: OnboardingPlan,
|
||||
planned: OnboardSelection,
|
||||
selection: OnboardSelection,
|
||||
): OnboardConfirmRequest | null {
|
||||
if (!buildPlanView(plan, planned, selection).confirm.enabled) return null;
|
||||
return {
|
||||
projectId: plan.project_id,
|
||||
expectedDigest: plan.digest,
|
||||
confirmed: true,
|
||||
options: selectionOptions(planned),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 接入报告 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS: Record<OnboardStatus, Badge> = {
|
||||
done: { label: "接入完成", tone: "ok" },
|
||||
partial: { label: "部分完成", tone: "warn" },
|
||||
failed: { label: "未执行:预检失败(零写入)", tone: "danger" },
|
||||
refused: { label: "已拒绝(零写入)", tone: "warn" },
|
||||
busy: { label: "该项目正在接入中(零写入)", tone: "info" },
|
||||
};
|
||||
|
||||
const STEP_STATUS: Record<OnboardStepReport["status"], Badge> = {
|
||||
converged: { label: "已一致", tone: "ok" },
|
||||
applied: { label: "已写入", tone: "ok" },
|
||||
skipped: { label: "已跳过", tone: "muted" },
|
||||
not_applicable: { label: "不适用", tone: "muted" },
|
||||
blocked: { label: "未执行", tone: "warn" },
|
||||
failed: { label: "失败", tone: "danger" },
|
||||
needs_human: { label: "需人工处理", tone: "warn" },
|
||||
};
|
||||
|
||||
/** 步骤 reason 的常见取值(其余原样展示) */
|
||||
const STEP_REASON: Record<string, string> = {
|
||||
not_installed: "未安装",
|
||||
control_plane: "控制面项目",
|
||||
wsl: "WSL 项目",
|
||||
manual_kept: "保留手写配置",
|
||||
other_project: "属于别的项目",
|
||||
deferred: "延后",
|
||||
};
|
||||
|
||||
export interface StepRow {
|
||||
step: string;
|
||||
label: string;
|
||||
status: OnboardStepReport["status"];
|
||||
statusLabel: string;
|
||||
tone: OnboardTone;
|
||||
/** reason 或 dep 的说明 */
|
||||
note?: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CompletionRow {
|
||||
item: string;
|
||||
source: OnboardCompletionItem["source"];
|
||||
ready: boolean;
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const PARITY_CHECKS: ParityCheck[] = ["A", "B", "C", "D", "E", "F"];
|
||||
|
||||
export const CHECK_LABEL: Record<ParityCheck, string> = {
|
||||
A: "项目规则",
|
||||
B: "lian-jing skill",
|
||||
C: "MCP 工具集",
|
||||
D: "get_diagnostics",
|
||||
E: "列出开发变量",
|
||||
F: "敏感读取认证链",
|
||||
};
|
||||
|
||||
const CELL_STATE: Record<ParityCellState, Badge> = {
|
||||
ready: { label: "就绪", tone: "ok" },
|
||||
fail: { label: "失败", tone: "danger" },
|
||||
unknown: { label: "未知", tone: "warn" },
|
||||
not_installed: { label: "未安装", tone: "muted" },
|
||||
not_applicable: { label: "不适用", tone: "muted" },
|
||||
blocked: { label: "未检查", tone: "warn" },
|
||||
timeout: { label: "超时", tone: "danger" },
|
||||
};
|
||||
|
||||
export const EVIDENCE_LABEL: Record<ParityEvidence, string> = {
|
||||
static: "静态",
|
||||
client_introspection: "客户端自省",
|
||||
protocol: "协议重放",
|
||||
session_event: "会话事件",
|
||||
server_trace: "服务端轨迹",
|
||||
};
|
||||
|
||||
/** 只等用户在客户端里操作的归因(与后端 completion 的 pending_user 同口径,仅用于着色) */
|
||||
const PENDING_USER_CAUSES: ParityCause[] = ["codex_project_untrusted"];
|
||||
|
||||
export interface L1CellView {
|
||||
state: ParityCellState;
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
evidence: ParityEvidence;
|
||||
evidenceLabel: string;
|
||||
cause?: ParityCause;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface L1ColumnView {
|
||||
client: string;
|
||||
label: string;
|
||||
version: string | null;
|
||||
route: string | null;
|
||||
cells: Record<ParityCheck, L1CellView>;
|
||||
}
|
||||
|
||||
export interface L1MatrixView {
|
||||
profile: ClientParityL1Report["profile"];
|
||||
allApplicableReady: boolean;
|
||||
note: string;
|
||||
columns: L1ColumnView[];
|
||||
}
|
||||
|
||||
function cellView(cell: ParityCell): L1CellView {
|
||||
const badge = CELL_STATE[cell.state] ?? { label: cell.state, tone: "warn" };
|
||||
const pendingUser = cell.state !== "ready" && cell.cause !== undefined && PENDING_USER_CAUSES.includes(cell.cause);
|
||||
const label = pendingUser
|
||||
? "待你信任"
|
||||
: cell.state === "blocked" && cell.dep
|
||||
? `未检查:${STEP_LABEL[cell.dep] ?? cell.dep} 步骤未完成`
|
||||
: badge.label;
|
||||
return {
|
||||
state: cell.state,
|
||||
label,
|
||||
tone: pendingUser ? "pending" : badge.tone,
|
||||
evidence: cell.evidence,
|
||||
evidenceLabel: EVIDENCE_LABEL[cell.evidence] ?? cell.evidence,
|
||||
cause: cell.cause,
|
||||
detail: cell.detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function l1MatrixView(report: ClientParityL1Report): L1MatrixView {
|
||||
return {
|
||||
profile: report.profile,
|
||||
allApplicableReady: report.all_applicable_ready,
|
||||
note: report.note,
|
||||
columns: report.clients.map((column) => {
|
||||
const cells = {} as Record<ParityCheck, L1CellView>;
|
||||
for (const check of PARITY_CHECKS) {
|
||||
const cell = column.cells[check];
|
||||
cells[check] = cell
|
||||
? cellView(cell)
|
||||
: { state: "unknown", label: "缺失", tone: "warn", evidence: "static", evidenceLabel: EVIDENCE_LABEL.static, detail: "报告未给出该格" };
|
||||
}
|
||||
return {
|
||||
client: column.client,
|
||||
label: STEP_LABEL[column.client] ?? column.client,
|
||||
version: column.version,
|
||||
route: column.route,
|
||||
cells,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type CommitView =
|
||||
| {
|
||||
state: "available";
|
||||
/** 「待你提交的 N 个文件」 */
|
||||
title: string;
|
||||
files: OnboardPendingFile[];
|
||||
otherChanges: number;
|
||||
/** 有非接入改动时提醒勿混进接入提交 */
|
||||
otherWarning?: string;
|
||||
suggestedMessage?: string;
|
||||
diffStat: string;
|
||||
}
|
||||
| { state: "unavailable"; reason: string }
|
||||
/** 未执行写入(refused / busy / failed)时不报告改动 */
|
||||
| { state: "none" };
|
||||
|
||||
export interface ReportView {
|
||||
status: OnboardStatus;
|
||||
statusLabel: string;
|
||||
tone: OnboardTone;
|
||||
summary: string;
|
||||
reason?: string;
|
||||
steps: StepRow[];
|
||||
/** 计入「完成」的项(不含 pending_user) */
|
||||
completion: CompletionRow[];
|
||||
/** 「待你操作」:只等你在客户端里操作、不计入完成判定的项(目前只有 Codex 信任) */
|
||||
pendingUser: CompletionRow[];
|
||||
l1?: L1MatrixView;
|
||||
l3: string;
|
||||
bypassWritten: boolean;
|
||||
writtenFiles: string[];
|
||||
commit: CommitView;
|
||||
nextActions: NextActionRow[];
|
||||
head?: { unchanged: boolean; label: string; tone: OnboardTone };
|
||||
/** refused 且附当前计划:须回到预览、按新计划重新确认 */
|
||||
replan?: OnboardingPlan;
|
||||
durationLabel: string;
|
||||
/** 结果提示:失败类状态以「❌」开头 */
|
||||
toast: string;
|
||||
}
|
||||
|
||||
function stepRow(step: OnboardStepReport): StepRow {
|
||||
const badge = STEP_STATUS[step.status] ?? { label: step.status, tone: "warn" };
|
||||
let note: string | undefined;
|
||||
if (step.status === "blocked") {
|
||||
note = `依赖「${STEP_LABEL[step.dep] ?? step.dep}」未完成`;
|
||||
} else if ("reason" in step && step.reason) {
|
||||
note = STEP_REASON[step.reason] ?? step.reason;
|
||||
}
|
||||
return {
|
||||
step: step.step,
|
||||
label: STEP_LABEL[step.step] ?? step.step,
|
||||
status: step.status,
|
||||
statusLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
note,
|
||||
detail: step.detail,
|
||||
};
|
||||
}
|
||||
|
||||
function completionRow(item: OnboardCompletionItem): CompletionRow {
|
||||
const pending = !item.ready && item.pending_user;
|
||||
return {
|
||||
item: item.item,
|
||||
source: item.source,
|
||||
ready: item.ready,
|
||||
label: item.ready ? "就绪" : pending ? "待你操作" : "未就绪",
|
||||
tone: item.ready ? "ok" : pending ? "pending" : "danger",
|
||||
detail: item.detail,
|
||||
};
|
||||
}
|
||||
|
||||
function commitView(report: OnboardReport): CommitView {
|
||||
const changes = report.repo_changes;
|
||||
if (!changes) return { state: "none" };
|
||||
if (changes.state === "unavailable") return { state: "unavailable", reason: changes.reason };
|
||||
return {
|
||||
state: "available",
|
||||
title: `待你提交的 ${changes.onboard_files.length} 个文件`,
|
||||
files: changes.onboard_files,
|
||||
otherChanges: changes.other_changes,
|
||||
otherWarning:
|
||||
changes.other_changes > 0
|
||||
? `另有 ${changes.other_changes} 处不属于接入的改动:只 add 上面的文件,不要 git add -A`
|
||||
: undefined,
|
||||
suggestedMessage: report.suggested_commit_message,
|
||||
diffStat: changes.diff_stat,
|
||||
};
|
||||
}
|
||||
|
||||
const FAILURE_STATUSES: OnboardStatus[] = ["failed", "refused", "busy"];
|
||||
|
||||
/** 报告 → 结果视图(分步结果、L1 矩阵、待你操作、待提交文件、next_actions) */
|
||||
export function buildReportView(report: OnboardReport): ReportView {
|
||||
const badge = STATUS[report.status] ?? { label: report.status, tone: "warn" };
|
||||
const completion = report.completion.map((item) => ({ item, row: completionRow(item) }));
|
||||
const failure = FAILURE_STATUSES.includes(report.status);
|
||||
return {
|
||||
status: report.status,
|
||||
statusLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
summary: report.summary,
|
||||
reason: report.reason,
|
||||
steps: report.steps.map(stepRow),
|
||||
completion: completion.filter(({ item }) => !item.pending_user).map(({ row }) => row),
|
||||
pendingUser: completion.filter(({ item }) => item.pending_user).map(({ row }) => row),
|
||||
l1: report.l1 ? l1MatrixView(report.l1) : undefined,
|
||||
l3: report.l3,
|
||||
bypassWritten: report.bypass_permissions,
|
||||
writtenFiles: report.written_files ?? [],
|
||||
commit: commitView(report),
|
||||
nextActions: report.next_actions.map(nextActionRow),
|
||||
head: report.head
|
||||
? report.head.unchanged
|
||||
? { unchanged: true, label: "HEAD 未变(炼境不 add、不 commit)", tone: "ok" }
|
||||
: { unchanged: false, label: "HEAD 已变化:写入期间有别的提交,请核对", tone: "warn" }
|
||||
: undefined,
|
||||
replan: report.status === "refused" ? report.plan : undefined,
|
||||
durationLabel: `${(report.duration_ms / 1000).toFixed(1)} 秒`,
|
||||
toast: failure
|
||||
? `❌ 一键接入${badge.label}:${report.reason ?? report.summary}`
|
||||
: `一键接入${badge.label} ✓`,
|
||||
};
|
||||
}
|
||||
@ -28,7 +28,10 @@ const EXEC_STEPS = [
|
||||
|
||||
export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
const qc = useQueryClient();
|
||||
/** 模板创建成功的项目 ID:自动弹接入预览(P8g),取消后可从完成页再次打开 */
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
// ── Step 1 ────────────────────────────────────────────────────────────────
|
||||
const [platform, setPlatform] = useState<"win" | "wsl">("win");
|
||||
@ -128,7 +131,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
const variantCmd = selectedVariants.length > 0
|
||||
? selectedVariants[selectedVariantIdx]?.initCommands
|
||||
: undefined;
|
||||
await createProjectFromTemplate(
|
||||
const result = await createProjectFromTemplate(
|
||||
selectedTemplateId,
|
||||
parentPath,
|
||||
projectName.trim(),
|
||||
@ -138,6 +141,8 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
setStep("done");
|
||||
setCreatedId(result.projectId);
|
||||
openOnboard({ projectId: result.projectId, projectName: projectName.trim() });
|
||||
} catch (e) {
|
||||
setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]);
|
||||
setHasError(true);
|
||||
@ -449,6 +454,14 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{step === "done" && createdId && (
|
||||
<button
|
||||
onClick={() => openOnboard({ projectId: createdId, projectName: projectName.trim() })}
|
||||
className="px-4 py-2 text-sm border border-blue-200 rounded-lg text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
一键接入
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={step === "executing"}
|
||||
|
||||
@ -7,7 +7,7 @@ export function Toast() {
|
||||
const isError = toast.startsWith("❌");
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-10 left-1/2 -translate-x-1/2 z-50 text-sm px-4 py-2 rounded-lg shadow-lg animate-fade-in flex items-center gap-2 max-w-[80vw] ${
|
||||
<div className={`fixed bottom-10 left-1/2 -translate-x-1/2 z-[70] text-sm px-4 py-2 rounded-lg shadow-lg animate-fade-in flex items-center gap-2 max-w-[80vw] ${
|
||||
isError ? "bg-red-900 text-white pointer-events-auto" : "bg-gray-900 text-white pointer-events-none"
|
||||
}`}>
|
||||
<span className="break-all">{toast}</span>
|
||||
|
||||
@ -1770,6 +1770,8 @@ export interface OnboardBlockedItem {
|
||||
target: string;
|
||||
reason: OnboardBlockReason;
|
||||
detail: string;
|
||||
/** 本体派生(BlockReason::skippable):可在确认时逐项「跳过」;界面据此给按钮,不自行判定 */
|
||||
skippable: boolean;
|
||||
}
|
||||
|
||||
/** 本机七步:token、shim、claude、codex、dsh、product_group_buff、git_hooks */
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
import type { Project } from "../lib/commands";
|
||||
import type { OnboardReport, Project } from "../lib/commands";
|
||||
|
||||
/** 一键接入预览的目标(agent-client-parity P8g):新建、模板创建、批量添加、蓝图治理都打开同一面板 */
|
||||
export interface OnboardTarget {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
/** 面板关闭时回调;未执行接入(取消 / 计划失败)时 report 为 null */
|
||||
onClosed?: (report: OnboardReport | null) => void;
|
||||
}
|
||||
|
||||
interface UIState {
|
||||
// Toast
|
||||
@ -10,6 +18,11 @@ interface UIState {
|
||||
editTarget: Project | null | undefined; // undefined = closed, null = new, obj = edit
|
||||
openEdit: (project?: Project | null) => void;
|
||||
closeEdit: () => void;
|
||||
|
||||
// 一键接入面板(全局挂载:新建弹窗保存后即卸载,面板须独立存在)
|
||||
onboardTarget: OnboardTarget | null;
|
||||
openOnboard: (target: OnboardTarget) => void;
|
||||
closeOnboard: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
@ -22,4 +35,8 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
editTarget: undefined,
|
||||
openEdit: (project) => set({ editTarget: project ?? null }),
|
||||
closeEdit: () => set({ editTarget: undefined }),
|
||||
|
||||
onboardTarget: null,
|
||||
openOnboard: (target) => set({ onboardTarget: target }),
|
||||
closeOnboard: () => set({ onboardTarget: null }),
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user