Wave 2.1: 返工黑屏/安装进度/官方配置三层/文档深度/真机列表

老板实测 5 条修复:DiagTab Hooks 黑屏、安装三阶段进度、配置按官方字段三层分组、FR-09 中文文档、detectCliAll 接入列表页。
This commit is contained in:
总工
2026-08-25 11:57:10 +08:00
parent 01996a80fe
commit a2fd5e369b
23 changed files with 1821 additions and 198 deletions
+13 -1
View File
@@ -7,7 +7,7 @@
use std::collections::BTreeMap;
use agentdock_adapter::{AdapterAction, DryRunPlan};
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, DetectResult, Engine, WriteResult};
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult};
use agentdock_diag::DiagnosticReport;
use serde_json::json;
use tauri::Emitter;
@@ -37,6 +37,12 @@ pub fn detect_cli(id: String, state: tauri::State<'_, Engine>) -> Result<DetectR
state.detect(&id).map_err(|e| e.to_string())
}
/// 批量检测全部 CLIdetectCliAll),供总览/目录/我的 CLI 接真机状态。
#[tauri::command(rename = "detectCliAll")]
pub fn detect_cli_all(state: tauri::State<'_, Engine>) -> Result<Vec<DetectResult>, String> {
state.detect_all().map_err(|e| e.to_string())
}
/// 干燥运行(previewAction):返回将执行的命令与影响面,不真正执行。
#[tauri::command(rename = "previewAction")]
pub fn preview_action(
@@ -107,6 +113,12 @@ pub fn write_config(
state.write_config(&id, &patch).map_err(|e| e.to_string())
}
/// 配置写入后的「生效检查」(verifyConfig):重读配置文件 + CLI 版本探测。
#[tauri::command(rename = "verifyConfig")]
pub fn verify_config(id: String, state: tauri::State<'_, Engine>) -> Result<ConfigVerifyResult, String> {
state.verify_config(&id).map_err(|e| e.to_string())
}
/// 授权状态(authStatus)。
#[tauri::command(rename = "authStatus")]
pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthStatus, String> {
+2
View File
@@ -48,11 +48,13 @@ pub fn run() {
commands::env::detect_env,
commands::catalog::list_catalog,
commands::cli::detect_cli,
commands::cli::detect_cli_all,
commands::cli::get_adapter,
commands::cli::preview_action,
commands::cli::run_action,
commands::cli::read_config,
commands::cli::write_config,
commands::cli::verify_config,
commands::cli::auth_status,
commands::cli::diagnose,
])
+2 -2
View File
@@ -43,11 +43,11 @@ export default function App() {
}
switch (page) {
case "overview":
return <OverviewPage onNavigate={navigate} />;
return <OverviewPage onNavigate={navigate} onOpenDetail={setDetailCliId} />;
case "catalog":
return <CatalogPage onOpenDetail={setDetailCliId} />;
case "my-cli":
return <MyCliPage onNavigate={navigate} />;
return <MyCliPage onNavigate={navigate} onOpenDetail={setDetailCliId} />;
case "config":
return <ConfigCenterPage onNavigate={navigate} />;
case "backup":
+151 -26
View File
@@ -1,10 +1,32 @@
import { useEffect, useMemo, useState } from "react";
import { Check, Eye, EyeOff, Lock } from "lucide-react";
import { readConfig, writeConfig } from "../ipc";
import type { ConfigFieldState, ConfigFormState, WriteResult } from "../ipc/types";
import { Check, ChevronDown, CircleAlert, ExternalLink, Eye, EyeOff, Lock, ShieldCheck } from "lucide-react";
import { readConfig, verifyConfig, writeConfig } from "../ipc";
import type {
ConfigFieldState,
ConfigFormState,
ConfigVerifyResult,
WriteResult,
} from "../ipc/types";
import { MonoChip } from "./MonoChip";
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) */
/** 官方授权方式中文名 */
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "账号授权(浏览器)";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条)
* Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」 */
export function ConfigForm({ id }: { id: string }) {
const [state, setState] = useState<ConfigFormState | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
@@ -13,7 +35,9 @@ export function ConfigForm({ id }: { id: string }) {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [result, setResult] = useState<WriteResult | null>(null);
const [verify, setVerify] = useState<ConfigVerifyResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -52,11 +76,29 @@ export function ConfigForm({ id }: { id: string }) {
if (!state) return null;
const hasFields = state.fields.length > 0;
const authFields = state.fields.filter((f) => f.group === "auth");
const commonFields = state.fields.filter((f) => f.group === "common");
const advancedFields = state.fields.filter((f) => f.group === "advanced");
function renderField(field: ConfigFieldState) {
return (
<Field
key={field.id}
field={field}
value={values[field.id] ?? ""}
saved={sensitiveSaved[field.id] ?? false}
revealed={!!revealed[field.id]}
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
/>
);
}
async function onSave() {
setSaving(true);
setSaved(false);
setResult(null);
setVerify(null);
setError(null);
const patch: Record<string, string> = {};
for (const f of state!.fields) {
@@ -78,6 +120,13 @@ export function ConfigForm({ id }: { id: string }) {
// 刷新回显
const s = await readConfig(id);
setState(s);
// 生效检查:写回的配置能否被 CLI 接受(配置文件解析 + CLI 版本探测)
try {
const v = await verifyConfig(id);
setVerify(v);
} catch {
setVerify(null);
}
} catch (e) {
setError(String(e));
} finally {
@@ -89,17 +138,59 @@ export function ConfigForm({ id }: { id: string }) {
<div className="config-form">
{!hasFields && <p className="panel-empty"> CLI </p>}
{state.fields.map((field) => (
<Field
key={field.id}
field={field}
value={values[field.id] ?? ""}
saved={sensitiveSaved[field.id] ?? false}
revealed={!!revealed[field.id]}
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
/>
))}
{/* 第一层:授权 / 登录(官方授权方式引导 + API Key 类字段) */}
{(state.auth_modes.length > 0 || authFields.length > 0) && (
<section className="config-section">
<h4 className="config-section-title"> / </h4>
{state.auth_modes.length > 0 && (
<ul className="auth-modes">
{state.auth_modes.map((m) => (
<li key={m.mode} className="auth-mode">
<span className="auth-mode-name">{authModeLabel(m.mode)}</span>
{m.notes_zh && <span className="auth-mode-note">{m.notes_zh}</span>}
{m.command.length > 0 && (
<span className="auth-mode-cmd">
<MonoChip>{m.command.join(" ")}</MonoChip>
</span>
)}
</li>
))}
</ul>
)}
{authFields.map(renderField)}
</section>
)}
{/* 第二层:常用配置 */}
{commonFields.length > 0 && (
<section className="config-section">
<h4 className="config-section-title"></h4>
{commonFields.map(renderField)}
</section>
)}
{/* 第三层:高级配置(默认折叠) */}
{advancedFields.length > 0 && (
<section className="config-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
>
<span className="config-section-title"></span>
<span className="advanced-badge"></span>
<ChevronDown
size={16}
strokeWidth={1.5}
className={advancedOpen ? "advanced-chevron open" : "advanced-chevron"}
aria-hidden="true"
/>
</button>
<p className="advanced-hint">使</p>
{advancedOpen && <div className="advanced-fields">{advancedFields.map(renderField)}</div>}
</section>
)}
{state.files.length > 0 && (
<div className="config-file-info">
@@ -121,6 +212,16 @@ export function ConfigForm({ id }: { id: string }) {
<Check size={14} strokeWidth={1.5} aria-hidden="true" />
</span>
)}
{verify && (
<span className={verify.ok ? "verify-ok" : "verify-fail"}>
{verify.ok ? (
<ShieldCheck size={14} strokeWidth={1.5} aria-hidden="true" />
) : (
<CircleAlert size={14} strokeWidth={1.5} aria-hidden="true" />
)}
{verify.message_zh}
</span>
)}
{result?.backup_path && (
<span className="save-detail">
· {result.backup_path.split(/[\\/]/).pop()}
@@ -158,7 +259,8 @@ function Field({
onToggleReveal: () => void;
}) {
const isPassword = field.sensitive;
const inputType = isPassword ? (revealed ? "text" : "password") : field.field_type === "url" ? "text" : "text";
const isEnum = field.field_type === "enum";
const inputType = isPassword ? (revealed ? "text" : "password") : "text";
return (
<div className="field">
<label className="field-label" htmlFor={`field-${field.id}`}>
@@ -171,15 +273,31 @@ function Field({
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
</span>
)}
<input
id={`field-${field.id}`}
type={inputType}
value={value}
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
{isEnum ? (
<select
id={`field-${field.id}`}
className="field-select"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">使 CLI </option>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label_zh}{o.value}
</option>
))}
</select>
) : (
<input
id={`field-${field.id}`}
type={inputType}
value={value}
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
)}
{isPassword && (
<button
type="button"
@@ -196,9 +314,16 @@ function Field({
{field.help_zh}
{isPassword && (
<span className="field-keyring-note">
{saved ? " · 已加密保存于系统密钥库" : " · 保存后写入系统密钥库,绝不明文落盘"}
{saved
? ` · 已加密保存于系统密钥库${field.env_key ? `${field.env_key}` : ""}`
: ` · 保存后写入系统密钥库,绝不明文落盘${field.env_key ? `${field.env_key}` : ""}`}
</span>
)}
{field.docs_url && (
<a className="field-docs-link" href={field.docs_url} target="_blank" rel="noreferrer">
<ExternalLink size={11} strokeWidth={1.5} aria-hidden="true" />
</a>
)}
</div>
</div>
);
@@ -0,0 +1,43 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
/** 顶层错误边界:任何渲染崩溃都不再整屏黑屏,而是给出可恢复提示 */
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// 记录到控制台便于排查,不含敏感信息
console.error("[agentdock] render error:", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div className="crash-screen">
<h1 className="crash-title"></h1>
<p className="crash-desc"></p>
<p className="crash-detail">{this.state.error.message}</p>
<button
type="button"
className="btn btn-primary"
onClick={() => this.setState({ error: null })}
>
</button>
</div>
);
}
return this.props.children;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from "react";
import { detectCliAll } from "../ipc";
import type { DetectResult } from "../ipc/types";
export interface UseDetectAllResult {
detectMap: Record<string, DetectResult>;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
/** 批量检测全部 CLI(总览/目录/我的 CLI 接真机状态) */
export function useDetectAll(): UseDetectAllResult {
const [detectMap, setDetectMap] = useState<Record<string, DetectResult>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const list = await detectCliAll();
const map: Record<string, DetectResult> = {};
for (const d of list) map[d.cli_id] = d;
setDetectMap(map);
setError(null);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { detectMap, loading, error, refresh };
}
+120
View File
@@ -8,6 +8,7 @@ import type {
CliAction,
CliActionEvent,
ConfigFormState,
ConfigVerifyResult,
DetectResult,
DiagnosticReport,
DryRunPlan,
@@ -33,6 +34,7 @@ export async function listCatalog(): Promise<CatalogEntry[]> {
// ---- Wave 2CLI 全链路 ----
export async function getAdapter(id: string): Promise<Adapter> {
if (!isTauri()) return mockAdapter(id);
return invoke<Adapter>("getAdapter", { id });
}
@@ -41,19 +43,27 @@ export async function detectCli(id: string): Promise<DetectResult> {
return invoke<DetectResult>("detectCli", { id });
}
export async function detectCliAll(): Promise<DetectResult[]> {
if (!isTauri()) return mockCatalog().map((c) => mockDetect(c.id));
return invoke<DetectResult[]>("detectCliAll");
}
export async function previewAction(
id: string,
action: CliAction,
channel?: string,
): Promise<DryRunPlan> {
if (!isTauri()) return mockDryRun(id, action);
return invoke<DryRunPlan>("previewAction", { id, action, channel });
}
export async function runAction(id: string, action: CliAction, channel?: string): Promise<void> {
if (!isTauri()) return;
return invoke<void>("runAction", { id, action, channel });
}
export async function readConfig(id: string): Promise<ConfigFormState> {
if (!isTauri()) return mockConfig(id);
return invoke<ConfigFormState>("readConfig", { id });
}
@@ -61,15 +71,30 @@ export async function writeConfig(
id: string,
patch: Record<string, string>,
): Promise<WriteResult> {
if (!isTauri()) return { backup_path: null, written_file: "~/.codex/config.toml", written_fields: Object.keys(patch), errors: [] };
return invoke<WriteResult>("writeConfig", { id, patch });
}
export async function verifyConfig(id: string): Promise<ConfigVerifyResult> {
if (!isTauri()) {
return {
cli_id: id,
ok: true,
level: "config_parsed",
message_zh: "配置文件已写入且可解析;CLI 未安装,未做 CLI 实际校验(以官方文档为准)。",
detail: null,
};
}
return invoke<ConfigVerifyResult>("verifyConfig", { id });
}
export async function authStatus(id: string): Promise<AuthStatus> {
if (!isTauri()) return mockAuth(id);
return invoke<AuthStatus>("authStatus", { id });
}
export async function diagnose(id: string): Promise<DiagnosticReport> {
if (!isTauri()) return { cli_id: id, findings: [] };
return invoke<DiagnosticReport>("diagnose", { id });
}
@@ -136,3 +161,98 @@ const mockPlatformEnv = (): PlatformEnv => ({
path_entries: ["C:\\Windows\\system32", "C:\\Windows", "C:\\Program Files\\nodejs"],
capabilities: { keyring: "ok", can_elevate: false },
});
const mockAdapter = (id: string): Adapter => ({
id,
name: "Codex CLI",
name_zh: "Codex CLI",
vendor: "OpenAI",
status: "available",
adapter_version: "1.1.0",
license: "Apache-2.0",
platforms: {
windows: { architectures: ["x64"], notes: "原生支持(PowerShell 脚本安装)" },
linux: { distributions: ["ubuntu"], architectures: ["x64"] },
},
official: {
homepage: "https://github.com/openai/codex",
docs: "https://learn.chatgpt.com/docs/codex/cli",
allowed_hosts: ["chatgpt.com"],
},
runtime_deps: [],
install: {
preferred: "npm",
channels: [
{ id: "npm", platforms: ["windows", "linux"], command: ["npm", "install", "-g", "@openai/codex"], package: "@openai/codex", elevate: "never", post_checks: ["detect"] },
],
},
detect: { executable: "codex", version_args: ["--version"], version_regex: "^codex-cli (\\d+\\.\\d+\\.\\d+)", version_unconfirmed: true },
update: { method: "npm_update", command: ["npm", "update", "-g", "@openai/codex"] },
uninstall: { method: "npm_uninstall", command: ["npm", "uninstall", "-g", "@openai/codex"], keep_config_default: true },
authorization: {
modes: [
{ mode: "browser_oauth", command: ["codex", "login"], status_command: ["codex", "login", "status"], notes_zh: "浏览器登录 ChatGPT 账号" },
{ mode: "device_code", command: ["codex", "login", "--device-auth"], notes_zh: "设备码登录" },
{ mode: "api_key", command: ["codex", "login", "--with-api-key"], env_keys: ["OPENAI_API_KEY"], notes_zh: "API Key 经 stdin 注入" },
],
},
configuration: {
files: [{ path: "~/.codex/config.toml", format: "toml", scope: "user" }],
environment: [{ key: "OPENAI_API_KEY", sensitive: true, maps_to_field: "api_key" }],
fields: [
{ id: "model", label_zh: "默认模型", help_zh: "官方键 model,如 gpt-5.6-terra", type: "string", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic" },
{ id: "approval_policy", label_zh: "审批策略", help_zh: "官方键 approval_policy", type: "enum", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [{ value: "untrusted", label_zh: "不信任(全部确认)" }, { value: "on-failure", label_zh: "失败时确认" }, { value: "never", label_zh: "永不确认" }] },
{ id: "openai_base_url", label_zh: "Base URL", help_zh: "官方简化键 openai_base_url", type: "url", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced" },
{ id: "api_key", label_zh: "API Key", help_zh: "存入系统密钥库(对应 OPENAI_API_KEY", sensitive: true, type: "string", storage: "keyring", docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced" },
],
},
documentation: {
quickstart_zh: "安装后运行 `codex` 登录,或用 `codex exec \"任务\"` 无头执行。",
install_zh: "推荐 `npm install -g @openai/codex`;也可走官方 PowerShell 脚本或 GitHub Releases 二进制。",
auth_zh: "三种授权:浏览器登录、设备码、API Key。",
commands: [
{ cmd: "codex", desc_zh: "启动交互式会话" },
{ cmd: "codex exec \"提示词\"", desc_zh: "无头单次执行" },
{ cmd: "codex exec --json \"提示词\"", desc_zh: "JSONL 事件流输出" },
{ cmd: "codex exec --output-schema <schema>", desc_zh: "约束输出 JSON Schema" },
{ cmd: "codex exec -o 文件", desc_zh: "输出写入文件" },
{ cmd: "codex exec --sandbox", desc_zh: "沙箱执行" },
{ cmd: "codex login", desc_zh: "浏览器登录" },
{ cmd: "codex login status", desc_zh: "查询登录状态" },
],
params: [
{ param: "--json", desc_zh: "JSONL 事件流输出" },
{ param: "--output-schema", desc_zh: "约束输出 JSON Schema" },
{ param: "-o / --output", desc_zh: "输出写入文件" },
{ param: "--ephemeral", desc_zh: "临时会话" },
{ param: "--sandbox", desc_zh: "沙箱执行" },
],
updated_at: "2026-08-25",
risks_zh: ["--version 官方文档未确认,适配器已标 version_unconfirmed 并实测兜底"],
},
});
const mockConfig = (id: string): ConfigFormState => ({
cli_id: id,
files: [{ path: "~/.codex/config.toml", format: "toml", scope: "user", exists: false, parse_ok: true, error: null }],
fields: [
{ id: "model", label_zh: "默认模型", help_zh: "官方键 model,如 gpt-5.6-terra", required: false, sensitive: false, field_type: "string", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [], env_key: null, group: "common" },
{ id: "approval_policy", label_zh: "审批策略", help_zh: "官方键 approval_policy", required: false, sensitive: false, field_type: "enum", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [{ value: "untrusted", label_zh: "不信任(全部确认)" }, { value: "on-failure", label_zh: "失败时确认" }, { value: "never", label_zh: "永不确认" }], env_key: null, group: "advanced" },
{ id: "openai_base_url", label_zh: "Base URL", help_zh: "官方简化键 openai_base_url", required: false, sensitive: false, field_type: "url", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced", options: [], env_key: null, group: "advanced" },
{ id: "api_key", label_zh: "API Key", help_zh: "存入系统密钥库(对应 OPENAI_API_KEY", required: false, sensitive: true, field_type: "string", storage: "keyring", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced", options: [], env_key: "OPENAI_API_KEY", group: "auth" },
],
environment: [{ key: "OPENAI_API_KEY", sensitive: true, maps_to_field: "api_key" }],
auth_modes: [
{ mode: "browser_oauth", notes_zh: "浏览器登录 ChatGPT 账号(需订阅计划)", command: ["codex", "login"] },
{ mode: "device_code", notes_zh: "设备码登录,不弹浏览器", command: ["codex", "login", "--device-auth"] },
{ mode: "api_key", notes_zh: "API Key 从系统密钥库读取,经 stdin 注入", command: [] },
],
});
const mockDryRun = (_id: string, action: CliAction): DryRunPlan => ({
commands: action === "install" ? [["npm", "install", "-g", "@openai/codex"]] : [["npm", "uninstall", "-g", "@openai/codex"]],
elevate: false,
elevate_reason_zh: null,
affected_files: ["~/.codex/config.toml"],
rollback_zh: action === "install" ? "可执行卸载命令回退;配置文件默认保留。" : "卸载默认保留配置,可重新安装恢复。",
});
+30
View File
@@ -97,6 +97,15 @@ export interface ConfigFieldState {
storage: string;
value: string | null;
has_value: boolean;
docs_url: string | null;
options: ConfigFieldOption[];
env_key: string | null;
group: "auth" | "common" | "advanced" | string;
}
export interface ConfigFieldOption {
value: string;
label_zh: string;
}
export interface ConfigFileState {
@@ -119,6 +128,13 @@ export interface ConfigFormState {
files: ConfigFileState[];
fields: ConfigFieldState[];
environment: EnvFieldState[];
auth_modes: AuthModeInfo[];
}
export interface AuthModeInfo {
mode: string;
notes_zh: string | null;
command: string[];
}
export interface WriteResult {
@@ -128,6 +144,14 @@ export interface WriteResult {
errors: string[];
}
export interface ConfigVerifyResult {
cli_id: string;
ok: boolean;
level: "cli_accepted" | "config_parsed" | "config_parse_failed" | "not_installed";
message_zh: string;
detail: string | null;
}
export interface DryRunPlan {
commands: string[][];
elevate: boolean;
@@ -141,6 +165,7 @@ export type ActionEventKind = "step" | "stdout" | "stderr" | "done" | "error";
export interface ActionEvent {
kind: ActionEventKind;
message: string;
phase: string | null;
data: unknown;
}
@@ -220,11 +245,16 @@ export interface Adapter {
storage: string;
platforms?: string[];
docs_url?: string | null;
options?: { value: string; label_zh: string }[];
group?: string | null;
}[];
} | null;
documentation?: {
quickstart_zh?: string | null;
install_zh?: string | null;
auth_zh?: string | null;
commands?: { cmd: string; desc_zh?: string | null }[];
params?: { param: string; desc_zh?: string | null }[];
updated_at?: string | null;
risks_zh?: string[];
} | null;
+4 -1
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
// 设计 Token(视觉规范 v1.2,唯一品味依据)
import "./tokens/color.css";
@@ -25,6 +26,8 @@ applyFxTier();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
+48 -31
View File
@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
import { Search } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
type StatusFilter = "all" | "installed" | "uninstalled" | "update";
type PlatformFilter = "all" | "windows" | "linux";
@@ -20,9 +22,10 @@ const PLATFORM_FILTERS: { key: PlatformFilter; label: string }[] = [
{ key: "linux", label: "Linux" },
];
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml */
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml + 真机 detect */
export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) {
const { entries, loading, error } = useCatalog();
const { detectMap, loading: detecting } = useDetectAll();
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [platform, setPlatform] = useState<PlatformFilter>("all");
@@ -34,14 +37,17 @@ export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => vo
const haystack = `${e.name_zh} ${e.name} ${e.vendor} ${e.id}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
// Wave 0:14 个均为未安装;已安装/可更新筛选结果为空(诚实占位)
if (status === "installed") return false;
const installed = detectMap[e.id]?.status === "installed";
// 状态筛选:接真机 detect 结果
if (status === "installed" && !installed) return false;
if (status === "uninstalled" && installed) return false;
// 可更新:Wave 2.1 不检测可更新,恒为空(诚实)
if (status === "update") return false;
// 平台:全部 14 个均支持 Windows + Ubuntu(调研底稿结论),筛选不改变集合
if (platform === "windows" || platform === "linux") return true;
return true;
});
}, [entries, query, status, platform]);
}, [entries, query, status, platform, detectMap]);
return (
<div className="catalog">
@@ -86,34 +92,45 @@ export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => vo
{loading && !error && <p className="panel-empty"></p>}
<div className="catalog-grid">
{filtered.map((entry) => (
<article
className="catalog-card"
key={entry.id}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(entry.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(entry.id);
}
}}
>
<div className="catalog-card-top">
<CliMonogram id={entry.id} name={entry.name} size={40} />
<div className="catalog-card-head">
<div className="catalog-card-name">{entry.name_zh}</div>
<div className="catalog-card-vendor">{entry.vendor}</div>
{filtered.map((entry) => {
const detect = detectMap[entry.id];
const installed = detect?.status === "installed";
return (
<article
className="catalog-card"
key={entry.id}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(entry.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(entry.id);
}
}}
>
<div className="catalog-card-top">
<CliMonogram id={entry.id} name={entry.name} size={40} />
<div className="catalog-card-head">
<div className="catalog-card-name">{entry.name_zh}</div>
<div className="catalog-card-vendor">{entry.vendor}</div>
</div>
</div>
</div>
<p className="catalog-card-desc"> · </p>
<div className="catalog-card-bottom">
<StatusBadge kind="uninstalled" label="未安装" />
<span className="catalog-platforms">Windows · Linux</span>
</div>
</article>
))}
<p className="catalog-card-desc"> · </p>
<div className="catalog-card-bottom">
{installed ? (
<span className="catalog-version">
<StatusBadge kind="installed" label="已安装" />
{detect?.version && <MonoChip>{detect.version}</MonoChip>}
</span>
) : (
<StatusBadge kind="uninstalled" label={detecting ? "检测中…" : "未安装"} />
)}
<span className="catalog-platforms">Windows · Linux</span>
</div>
</article>
);
})}
{!loading && filtered.length === 0 && (
<p className="panel-empty catalog-empty"> CLI</p>
)}
+238 -35
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
ArrowLeft,
BookOpen,
Check,
ChevronDown,
CircleHelp,
ClipboardList,
@@ -35,6 +36,12 @@ const TABS: { key: Tab; label: string }[] = [
{ key: "diag", label: "诊断" },
];
type RunPhase = "prepare" | "exec" | "verify";
type RunOutcome =
| { status: "success"; action: CliAction; detect: DetectResult | null }
| { status: "failed"; action: CliAction; message: string };
/** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */
function AuthLight({ auth }: { auth: AuthStatus | null }) {
if (!auth) return <span className="status-dot unknown" aria-hidden="true" />;
@@ -90,7 +97,9 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
const [running, setRunning] = useState(false);
const [runTitle, setRunTitle] = useState("");
const [log, setLog] = useState<ActionEvent[]>([]);
const logEndRef = useRef<HTMLDivElement | null>(null);
const [phase, setPhase] = useState<RunPhase>("prepare");
const [outcome, setOutcome] = useState<RunOutcome | null>(null);
const currentActionRef = useRef<CliAction>("install");
const installed = detect?.status === "installed";
@@ -102,14 +111,18 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
const ev = payload.event;
if (ev.kind === "done") {
setRunning(false);
setPhase("verify");
const d = (ev.data as DetectResult | null) ?? null;
setOutcome({ status: "success", action: currentActionRef.current, detect: d });
void refresh();
return;
}
if (ev.kind === "error") {
setRunning(false);
setLog((l) => [...l, { kind: "error", message: ev.message, data: null }]);
setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message });
return;
}
if (ev.phase) setPhase(ev.phase as RunPhase);
setLog((l) => [...l, ev]);
}).then((fn) => {
unlisten = fn;
@@ -117,10 +130,6 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
return () => unlisten?.();
}, [id, refresh]);
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [log]);
async function openConfirm(action: CliAction) {
const plan = await previewAction(id, action);
setConfirm({ action, plan });
@@ -129,13 +138,22 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
async function confirmRun() {
if (!confirm) return;
const action = confirm.action;
currentActionRef.current = action;
setConfirm(null);
setRunning(true);
setOutcome(null);
setPhase("prepare");
setLog([]);
setRunTitle(action === "install" ? "正在安装" : action === "uninstall" ? "正在卸载" : "正在执行");
await runAction(id, action);
}
function closeRun() {
setRunning(false);
setOutcome(null);
setLog([]);
}
if (loading) {
return <p className="panel-empty"></p>;
}
@@ -220,7 +238,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
</nav>
<section className="cli-detail-body">
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} />}
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} auth={auth} />}
{tab === "config" && <ConfigForm id={id} />}
{tab === "docs" && <DocsTab adapter={adapter} />}
{tab === "diag" && <DiagTab id={id} />}
@@ -246,32 +264,109 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
</Modal>
)}
{/* 流式执行日志弹窗 */}
{running && (
<Modal title={`${runTitle} ${adapter.name_zh}`} footer={null}>
<div className="run-log" aria-live="polite">
{log.length === 0 && (
<div className="run-log-wait">
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
</div>
)}
{log.map((l, i) => (
<div key={i} className={`run-log-line ${l.kind}`}>
{l.kind === "step" ? <ClipboardList size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stdout" ? <Terminal size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stderr" ? <ShieldAlert size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
<span>{l.message}</span>
</div>
))}
<div ref={logEndRef} />
</div>
{/* 流式执行日志弹窗(安装/卸载全程可见:步骤进度 + stdout/stderr + 终态) */}
{(running || outcome) && (
<Modal
title={outcome ? `${runTitle} ${adapter.name_zh} · ${outcome.status === "success" ? "完成" : "失败"}` : `${runTitle} ${adapter.name_zh}`}
onClose={running ? undefined : closeRun}
footer={
running ? null : (
<button type="button" className="btn btn-primary" onClick={closeRun}>
</button>
)
}
>
<RunBody phase={phase} log={log} outcome={outcome} />
</Modal>
)}
</div>
);
}
/** 执行进度三阶段(准备 / 执行 / 复检) */
function PhaseSteps({ phase }: { phase: RunPhase }) {
const steps: { key: RunPhase; label: string }[] = [
{ key: "prepare", label: "准备" },
{ key: "exec", label: "执行" },
{ key: "verify", label: "复检" },
];
const order: Record<RunPhase, number> = { prepare: 0, exec: 1, verify: 2 };
const cur = order[phase];
return (
<div className="run-phases" aria-label="安装进度">
{steps.map((s, i) => (
<div key={s.key} className={`run-phase ${i < cur ? "done" : i === cur ? "active" : ""}`}>
<span className="run-phase-dot">{i < cur ? "✓" : i + 1}</span>
<span className="run-phase-label">{s.label}</span>
</div>
))}
</div>
);
}
function RunBody({
phase,
log,
outcome,
}: {
phase: RunPhase;
log: ActionEvent[];
outcome: RunOutcome | null;
}) {
return (
<div className="run-body">
<PhaseSteps phase={phase} />
<div className="run-log" aria-live="polite">
{log.length === 0 && !outcome && (
<div className="run-log-wait">
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
</div>
)}
{log.map((l, i) => (
<div key={i} className={`run-log-line ${l.kind}`}>
{l.kind === "step" ? <ClipboardList size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stdout" ? <Terminal size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stderr" ? <ShieldAlert size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
<span>{l.message}</span>
</div>
))}
<div ref={useAutoScroll(log)} />
</div>
{outcome?.status === "success" && (
<div className="run-result success">
<Check size={14} strokeWidth={1.5} aria-hidden="true" />
<span>
{outcome.action === "install" ? "安装成功" : outcome.action === "uninstall" ? "卸载完成" : "执行完成"}
</span>
{outcome.detect?.status === "installed" && (
<span className="run-result-version">
{outcome.detect.version ?? ""}
{outcome.detect.executable ? ` · ${outcome.detect.executable}` : ""}
</span>
)}
</div>
)}
{outcome?.status === "failed" && (
<div className="run-result failed">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
<span>{outcome.message}</span>
</div>
)}
</div>
);
}
/** 自动滚动到底部(把 ref 挂在日志末尾) */
function useAutoScroll(dep: unknown) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
ref.current?.scrollIntoView({ behavior: "smooth" });
}, [dep]);
return ref;
}
function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction }) {
return (
<div className="confirm-body">
@@ -320,8 +415,34 @@ function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction })
);
}
function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResult | null }) {
function OverviewTab({
adapter,
detect,
auth,
}: {
adapter: Adapter;
detect: DetectResult | null;
auth: AuthStatus | null;
}) {
const channels = adapter.install?.channels ?? [];
const authModes = adapter.authorization?.modes ?? [];
const platforms: string[] = [];
if (adapter.platforms?.windows) platforms.push("Windows");
if (adapter.platforms?.linux) platforms.push("Ubuntu");
const rows: { label: string; value: ReactNode }[] = [
{ label: "厂商", value: adapter.vendor },
{ label: "版本", value: detect?.version ? <MonoChip>{detect.version}</MonoChip> : detectLabel(detect) },
{ label: "路径", value: detect?.executable ? <MonoChip>{detect.executable}</MonoChip> : "—" },
{ label: "授权状态", value: auth ? auth.detail_zh : "—" },
{ label: "支持平台", value: platforms.length > 0 ? platforms.join(" · ") : "—" },
{
label: "授权方式",
value: authModes.length > 0 ? authModes.map((m) => authModeLabel(m.mode)).join(" / ") : "—",
},
{ label: "适配器版本", value: adapter.adapter_version ?? "—" },
];
return (
<div className="detail-overview">
<div className="detail-block">
@@ -330,6 +451,19 @@ function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResu
{adapter.documentation?.quickstart_zh ?? "参见官方文档。"}
</p>
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<dl className="detail-meta">
{rows.map((r) => (
<div key={r.label} className="detail-meta-row">
<dt className="detail-meta-label">{r.label}</dt>
<dd className="detail-meta-value">{r.value}</dd>
</div>
))}
</dl>
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
{channels.length === 0 ? (
@@ -346,6 +480,23 @@ function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResu
</ul>
)}
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<div className="detail-links">
{adapter.official?.homepage && (
<a className="detail-link" href={adapter.official.homepage} target="_blank" rel="noreferrer">
</a>
)}
{adapter.official?.docs && (
<a className="detail-link" href={adapter.official.docs} target="_blank" rel="noreferrer">
</a>
)}
</div>
</div>
{detect && detect.status !== "installed" && detect.status !== "not_installed" && (
<div className="detail-block detail-note">
<CircleHelp size={14} strokeWidth={1.5} aria-hidden="true" />
@@ -356,10 +507,44 @@ function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResu
);
}
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "浏览器授权";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
function DocsTab({ adapter }: { adapter: Adapter }) {
const doc = adapter.documentation;
const docUrl = adapter.official?.docs;
return (
<div className="detail-docs">
{doc?.quickstart_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.quickstart_zh}</p>
</div>
)}
{doc?.install_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.install_zh}</p>
</div>
)}
{doc?.auth_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.auth_zh}</p>
</div>
)}
<div className="detail-block">
<h3 className="detail-block-title">
<BookOpen size={14} strokeWidth={1.5} aria-hidden="true" />
@@ -377,6 +562,19 @@ function DocsTab({ adapter }: { adapter: Adapter }) {
</ul>
)}
</div>
{(doc?.params ?? []).length > 0 && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<ul className="detail-commands">
{doc!.params!.map((p) => (
<li key={p.param} className="detail-command">
<MonoChip>{p.param}</MonoChip>
{p.desc_zh && <span className="detail-command-desc">{p.desc_zh}</span>}
</li>
))}
</ul>
</div>
)}
{(doc?.risks_zh ?? []).length > 0 && (
<div className="detail-block detail-note">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
@@ -387,11 +585,16 @@ function DocsTab({ adapter }: { adapter: Adapter }) {
</div>
</div>
)}
{adapter.official?.homepage && (
<div className="detail-block detail-block-text">
<MonoChip>{adapter.official.homepage}</MonoChip>
</div>
)}
<div className="detail-block detail-doc-footer">
{docUrl && (
<a className="detail-link" href={docUrl} target="_blank" rel="noreferrer">
</a>
)}
<span className="detail-doc-meta">
{adapter.adapter_version ?? "—"} · {doc?.updated_at ?? "—"}
</span>
</div>
</div>
);
}
@@ -437,7 +640,7 @@ function DiagTab({ id }: { id: string }) {
if (error) return <p className="panel-empty">{error}</p>;
if (!report) return null;
const findings = useMemo(() => report.findings, [report]);
const findings = report.findings;
return (
<div className="diag-result">
+100 -13
View File
@@ -1,20 +1,107 @@
import { SquareTerminal } from "lucide-react";
import { useEffect, useState } from "react";
import { ChevronRight, SquareTerminal } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { authStatus } from "../ipc";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
import type { AuthStatus } from "../ipc/types";
import type { PageKey } from "../components/Sidebar";
/** 我的 CLI(PRD §7):版本 / 路径 / 授权状态。空态按 v1.3 §3.7。 */
export function MyCliPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
/** 单个已安装 CLI 的授权状态(懒加载) */
function AuthBadge({ id }: { id: string }) {
const [auth, setAuth] = useState<AuthStatus | null>(null);
useEffect(() => {
let cancelled = false;
authStatus(id)
.then((a) => {
if (!cancelled) setAuth(a);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [id]);
if (!auth) return <span className="mycli-auth"></span>;
const ok = auth.status === "authorized";
return (
<div className="placeholder-page">
<div className="placeholder-icon">
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" />
<span className="mycli-auth">
<span className={ok ? "status-dot ok" : "status-dot unknown"} aria-hidden="true" />
{ok ? "已授权" : "未授权"}
</span>
);
}
/** 我的 CLI(PRD §7):展示已安装列表(版本 / 路径 / 授权状态)。空态按 v1.3 §3.7。 */
export function MyCliPage({
onNavigate,
onOpenDetail,
}: {
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}) {
const { entries } = useCatalog();
const { detectMap, loading } = useDetectAll();
const installed = entries.filter((e) => detectMap[e.id]?.status === "installed");
if (loading) {
return <p className="panel-empty"> CLI</p>;
}
if (installed.length === 0) {
return (
<div className="placeholder-page">
<div className="placeholder-icon">
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" />
</div>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
CLI
</p>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
</div>
);
}
return (
<div className="mycli">
<div className="panel mycli-list">
<div className="panel-head">
<h2 className="panel-title"> CLI{installed.length}</h2>
</div>
{installed.map((entry) => {
const d = detectMap[entry.id];
return (
<button
key={entry.id}
type="button"
className="mycli-row"
onClick={() => onOpenDetail(entry.id)}
>
<CliMonogram id={entry.id} name={entry.name} size={36} />
<div className="mycli-row-main">
<span className="mycli-row-name">{entry.name_zh}</span>
{d?.version && (
<span className="mycli-row-version">
<MonoChip>{d.version}</MonoChip>
</span>
)}
</div>
{d?.executable && (
<span className="mycli-row-path">
<MonoChip>{d.executable}</MonoChip>
</span>
)}
<AuthBadge id={entry.id} />
<ChevronRight size={16} strokeWidth={1.5} className="mycli-row-arrow" aria-hidden="true" />
</button>
);
})}
</div>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
CLI
</p>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
</div>
);
}
+27 -9
View File
@@ -2,11 +2,12 @@ import { useState } from "react";
import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react";
import { useEnv } from "../hooks/useEnv";
import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { KpiCard } from "../components/KpiCard";
import { MonoChip } from "../components/MonoChip";
import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram";
import type { PlatformEnv, RuntimeInfo, RuntimeStatus, CatalogEntry } from "../ipc/types";
import type { CatalogEntry, DetectResult, PlatformEnv, RuntimeInfo, RuntimeStatus } from "../ipc/types";
import type { PageKey } from "../components/Sidebar";
/** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */
@@ -292,30 +293,38 @@ function QuickActions({
/** CLI 状态网格块(v1.3 §3.1.4;首跑精简为两行 + 安装入口,§3.1.5) */
function CliBlock({
entry,
detect,
compact,
onNavigate,
onOpenDetail,
}: {
entry: CatalogEntry;
detect: DetectResult | undefined;
compact: boolean;
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}) {
const installed = entry.status === "installed";
const installed = detect?.status === "installed";
return (
<div className="cli-block">
<div className="cli-block" role="button" tabIndex={0} onClick={() => onOpenDetail(entry.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpenDetail(entry.id); } }}>
<div className="cli-block-top">
<CliMonogram id={entry.id} name={entry.name} size={32} />
<span className="cli-block-name">{entry.name_zh}</span>
<StatusBadge kind={installed ? "installed" : "uninstalled"} label={installed ? "已安装" : "未安装"} />
</div>
<div className="cli-block-version">
{installed ? null : <span className="version-missing"></span>}
{installed ? (
detect?.version ? <MonoChip>{detect.version}</MonoChip> : null
) : (
<span className="version-missing"></span>
)}
</div>
{!compact && (
<div className="cli-block-auth">
{installed ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
<span>{detect?.version ? `v${detect.version}` : "已安装"}</span>
</>
) : (
<>
@@ -327,7 +336,7 @@ function CliBlock({
)}
{compact && (
<div className="cli-block-install">
<button type="button" className="link-btn" onClick={() => onNavigate("catalog")}>
<button type="button" className="link-btn" onClick={(e) => { e.stopPropagation(); onNavigate("catalog"); }}>
</button>
</div>
@@ -338,14 +347,16 @@ function CliBlock({
interface OverviewProps {
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}
/** 总览页(视觉规范 §3.1 + PRD §7):首跑空态 / KPI + 诊断/快速操作 + CLI 状态网格 + 本机环境 */
export function OverviewPage({ onNavigate }: OverviewProps) {
export function OverviewPage({ onNavigate, onOpenDetail }: OverviewProps) {
const { env } = useEnv();
const { entries } = useCatalog();
const { detectMap } = useDetectAll();
const installedCount = entries.filter((e) => e.status === "installed").length;
const installedCount = entries.filter((e) => detectMap[e.id]?.status === "installed").length;
const firstRun = installedCount === 0; // 无安装且无诊断记录(当前无诊断引擎)
const warningCount = env ? runtimeProblems(env).length : 0;
@@ -384,7 +395,14 @@ export function OverviewPage({ onNavigate }: OverviewProps) {
</div>
<div className="cli-status-grid">
{entries.map((entry) => (
<CliBlock key={entry.id} entry={entry} compact={firstRun} onNavigate={onNavigate} />
<CliBlock
key={entry.id}
entry={entry}
detect={detectMap[entry.id]}
compact={firstRun}
onNavigate={onNavigate}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
+390
View File
@@ -1144,6 +1144,7 @@ button {
.overview .overview-mid {
grid-column: 1 / 3;
grid-row: 2;
margin-bottom: 0;
}
@@ -1153,6 +1154,7 @@ button {
.overview .env-panel {
grid-column: 3;
grid-row: 2;
margin-top: 0;
}
}
@@ -1815,3 +1817,391 @@ button {
.save-error {
color: var(--ad-danger);
}
/* ============================================================
* Wave 2.1:安装进度 / 配置校验 / 详情信息 / 目录真机状态 / 我的 CLI
* 全部颜色来自 tokens/
* ============================================================ */
/* ---------- 执行进度三阶段(安装弹窗) ---------- */
.run-body {
display: flex;
flex-direction: column;
gap: var(--ad-space-4);
}
.run-phases {
display: flex;
align-items: center;
gap: var(--ad-space-2);
}
.run-phase {
display: flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-text-3);
font-size: var(--ad-text-s-size);
}
.run-phase-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid var(--ad-border);
font-size: var(--ad-text-xs-size);
}
.run-phase.done {
color: var(--ad-success);
}
.run-phase.done .run-phase-dot {
border-color: var(--ad-success);
color: var(--ad-success);
}
.run-phase.active {
color: var(--ad-primary);
}
.run-phase.active .run-phase-dot {
border-color: var(--ad-primary);
color: var(--ad-primary);
box-shadow: var(--ad-glow-primary);
}
.run-result {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ad-space-2);
padding: var(--ad-space-3) var(--ad-space-4);
border-radius: var(--ad-radius-m);
font-size: var(--ad-text-s-size);
}
.run-result.success {
color: var(--ad-success);
border: 1px solid var(--ad-border);
background: var(--ad-bg-1);
}
.run-result.failed {
color: var(--ad-danger);
border: 1px solid var(--ad-border);
background: var(--ad-bg-1);
}
.run-result-version {
color: var(--ad-text-2);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
word-break: break-all;
}
/* ---------- 详情概览:基本信息表 + 官方链接 ---------- */
.detail-meta {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.detail-meta-row {
display: flex;
align-items: center;
gap: var(--ad-space-3);
}
.detail-meta-label {
width: 88px;
flex-shrink: 0;
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
.detail-meta-value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ad-space-2);
min-width: 0;
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
}
.detail-links {
display: flex;
gap: var(--ad-space-4);
}
.detail-link {
color: var(--ad-primary);
font-size: var(--ad-text-m-size);
text-decoration: none;
}
.detail-link:hover {
text-decoration: underline;
}
.detail-doc-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--ad-space-3);
padding-top: var(--ad-space-4);
border-top: 1px solid var(--ad-border);
}
.detail-doc-meta {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
/* ---------- 配置表单:下拉 + 官方链接 + 校验结果 ---------- */
.field-select {
width: 100%;
height: 36px;
padding: 0 var(--ad-space-3);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
background: var(--ad-bg-0);
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
outline: none;
}
.field-select:focus {
border-color: var(--ad-primary);
box-shadow: var(--ad-glow-primary);
}
.field-docs-link {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: var(--ad-space-2);
color: var(--ad-primary);
text-decoration: none;
}
.field-docs-link:hover {
text-decoration: underline;
}
.verify-ok {
display: inline-flex;
align-items: flex-start;
gap: var(--ad-space-1);
color: var(--ad-success);
font-size: var(--ad-text-xs-size);
}
.verify-fail {
display: inline-flex;
align-items: flex-start;
gap: var(--ad-space-1);
color: var(--ad-danger);
font-size: var(--ad-text-xs-size);
}
/* ---------- 目录真机版本位 ---------- */
.catalog-version {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
}
/* ---------- 我的 CLI 列表 ---------- */
.mycli {
max-width: var(--ad-frame-max);
margin: 0 auto;
}
.mycli-list {
padding: var(--ad-space-4);
}
.mycli-row {
display: flex;
align-items: center;
gap: var(--ad-space-3);
width: 100%;
padding: var(--ad-space-3) var(--ad-space-2);
border: none;
border-bottom: 1px solid var(--ad-border);
background: none;
cursor: pointer;
text-align: left;
}
.mycli-row:last-child {
border-bottom: none;
}
.mycli-row:hover {
background: var(--ad-bg-3);
}
.mycli-row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.mycli-row-name {
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
}
.mycli-row-version {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.mycli-row-path {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mycli-auth {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
white-space: nowrap;
}
.mycli-row-arrow {
color: var(--ad-text-3);
}
/* ---------- 错误边界(防黑屏) ---------- */
.crash-screen {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--ad-space-4);
padding: var(--ad-space-8);
}
.crash-title {
font-size: var(--ad-text-xl-size);
font-weight: var(--ad-text-xl-weight);
}
.crash-desc {
color: var(--ad-text-2);
font-size: var(--ad-text-m-size);
}
.crash-detail {
color: var(--ad-text-3);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
word-break: break-all;
}
/* ---------- 配置表单三层分组(授权/常用/高级) ---------- */
.config-section {
display: flex;
flex-direction: column;
gap: var(--ad-space-3);
}
.config-section-title {
font-size: var(--ad-text-m-size);
font-weight: var(--ad-text-m-weight);
color: var(--ad-text-1);
}
.auth-modes {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
padding: var(--ad-space-3);
margin: 0;
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
background: var(--ad-bg-1);
}
.auth-mode {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--ad-space-2);
}
.auth-mode-name {
color: var(--ad-primary);
font-size: var(--ad-text-s-size);
white-space: nowrap;
}
.auth-mode-note {
flex: 1;
min-width: 0;
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
.auth-mode-cmd {
color: var(--ad-text-3);
}
.config-advanced-toggle {
display: flex;
align-items: center;
gap: var(--ad-space-2);
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.advanced-badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 var(--ad-space-2);
border-radius: var(--ad-radius-s);
background: var(--ad-attention);
color: var(--ad-bg-0);
font-size: var(--ad-text-xs-size);
}
.advanced-chevron {
color: var(--ad-text-2);
transition: transform var(--ad-dur-fast) var(--ad-ease-out);
}
.advanced-chevron.open {
transform: rotate(180deg);
}
.advanced-hint {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
.advanced-fields {
display: flex;
flex-direction: column;
gap: var(--ad-space-5);
}