Wave 2: 五件套打通全链路(安装/检测/配置/授权/诊断)

- 五个适配器 YAML 全字段补齐(codex/claude-code/gemini/kimi/opencode)
- agentdock-config:TOML/JSON 编解码 + 原子写 + 自动备份
- agentdock-diag:PATH/依赖版本/版本冲突/配置损坏四类规则
- agentdock-core:detectCli/previewAction/runAction 流式/readConfig/writeConfig/authStatus/diagnose
- Tauri IPC 命令 + CLI 详情页/安装确认弹窗/配置表单/诊断页
- cargo test 75 项通过(含 4 项真机 ignored 自测),前端 build 通过
This commit is contained in:
leefer
2026-08-25 08:58:38 +08:00
parent c47494180b
commit 01996a80fe
33 changed files with 9404 additions and 152 deletions
+3
View File
@@ -19,3 +19,6 @@ serde_json = "1"
agentdock-platform = { path = "../../../crates/agentdock-platform" }
agentdock-adapter = { path = "../../../crates/agentdock-adapter" }
agentdock-store = { path = "../../../crates/agentdock-store" }
agentdock-core = { path = "../../../crates/agentdock-core" }
agentdock-secrets = { path = "../../../crates/agentdock-secrets" }
agentdock-diag = { path = "../../../crates/agentdock-diag" }
+121
View File
@@ -0,0 +1,121 @@
//! IPC 命令:CLI 全链路(detectCli / previewAction / runAction / readConfig /
//! writeConfig / authStatus / authorize / diagnose,架构 §2 IPC 契约)
//!
//! 编排全部走 `agentdock-core::Engine`。runAction 通过 Tauri 事件流式回传
//! stdout/stderr;敏感输出经 `agentdock-secrets::redact` 脱敏后才进事件。
use std::collections::BTreeMap;
use agentdock_adapter::{AdapterAction, DryRunPlan};
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, DetectResult, Engine, WriteResult};
use agentdock_diag::DiagnosticReport;
use serde_json::json;
use tauri::Emitter;
/// 把 action 字符串映射为 AdapterActionsnake_case,与 IPC 契约一致)。
fn parse_action(action: &str) -> Result<AdapterAction, String> {
match action {
"install" => Ok(AdapterAction::Install),
"update" => Ok(AdapterAction::Update),
"uninstall" => Ok(AdapterAction::Uninstall),
"write_config" => Ok(AdapterAction::WriteConfig),
"authorize" => Ok(AdapterAction::Authorize),
"repair" => Ok(AdapterAction::Repair),
other => Err(format!("未知动作: {other}")),
}
}
/// 读取单个适配器完整定义(CLI 详情页展示文档/渠道/许可用)。
#[tauri::command(rename = "getAdapter")]
pub fn get_adapter(id: String, state: tauri::State<'_, Engine>) -> Result<agentdock_adapter::Adapter, String> {
state.adapter(&id).map_err(|e| e.to_string())
}
/// 检测单个 CLIdetectCli)。
#[tauri::command(rename = "detectCli")]
pub fn detect_cli(id: String, state: tauri::State<'_, Engine>) -> Result<DetectResult, String> {
state.detect(&id).map_err(|e| e.to_string())
}
/// 干燥运行(previewAction):返回将执行的命令与影响面,不真正执行。
#[tauri::command(rename = "previewAction")]
pub fn preview_action(
id: String,
action: String,
channel: Option<String>,
state: tauri::State<'_, Engine>,
) -> Result<DryRunPlan, String> {
let action = parse_action(&action)?;
let mut plan = state.preview(&id, action).map_err(|e| e.to_string())?;
// 渠道覆盖时调整计划里的命令为所选渠道(供确认弹窗展示)
if let Some(ch) = channel {
if let Some(adapter) = state.adapter(&id).ok() {
if let Some(install) = adapter.install.as_ref() {
if let Some(target) = install.channels.iter().find(|c| c.id == ch) {
if !target.command.is_empty() {
plan.commands = vec![target.command.clone()];
plan.elevate = matches!(target.elevate.as_deref(), Some("if_needed") | Some("required"));
plan.elevate_reason_zh = target.elevate_reason_zh.clone();
}
}
}
}
}
Ok(plan)
}
/// 流式执行动作(runAction):事件经 `cli-action-event` 回传。
#[tauri::command(rename = "runAction")]
pub fn run_action(
app: tauri::AppHandle,
id: String,
action: String,
channel: Option<String>,
state: tauri::State<'_, Engine>,
) -> Result<(), String> {
let action = parse_action(&action)?;
let engine = state.inner().clone();
let opts = ActionOpts { channel };
std::thread::spawn(move || {
let cli_id = id.clone();
let result = engine.run(&id, action, &opts, |ev| {
let _ = app.emit("cli-action-event", json!({ "cli_id": cli_id, "event": ev }));
});
if let Err(e) = result {
let _ = app.emit(
"cli-action-event",
json!({ "cli_id": cli_id, "event": ActionEvent::error(e.to_string()) }),
);
}
});
Ok(())
}
/// 读取配置表单状态(readConfig)。
#[tauri::command(rename = "readConfig")]
pub fn read_config(id: String, state: tauri::State<'_, Engine>) -> Result<ConfigFormState, String> {
state.read_config(&id).map_err(|e| e.to_string())
}
/// 写配置(writeConfig):patch 为 { field_id: value }。
#[tauri::command(rename = "writeConfig")]
pub fn write_config(
id: String,
patch: BTreeMap<String, String>,
state: tauri::State<'_, Engine>,
) -> Result<WriteResult, String> {
state.write_config(&id, &patch).map_err(|e| e.to_string())
}
/// 授权状态(authStatus)。
#[tauri::command(rename = "authStatus")]
pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthStatus, String> {
state.auth_status(&id).map_err(|e| e.to_string())
}
/// 诊断(diagnose):内部实时取本机环境快照。
#[tauri::command(rename = "diagnose")]
pub fn diagnose(id: String, state: tauri::State<'_, Engine>) -> Result<DiagnosticReport, String> {
let env = agentdock_platform::detect::detect_env();
state.diagnose(&id, &env).map_err(|e| e.to_string())
}
@@ -1,2 +1,3 @@
pub mod catalog;
pub mod cli;
pub mod env;
+20 -1
View File
@@ -1,6 +1,12 @@
mod commands;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::Manager;
use agentdock_core::Engine;
use agentdock_secrets::KeyringSecretStore;
/// 解析适配器目录。
/// 优先级:环境变量 AGENTDOCK_ADAPTERS_DIR > CWD 相对路径(tauri dev 时 CWD=apps/desktop
@@ -26,16 +32,29 @@ fn adapters_dir() -> PathBuf {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|_app| {
.setup(|app| {
// 自测标记:`tauri dev` 启动后日志出现该行即代表壳已就绪
println!("[agentdock] window-ready");
// Wave 0:初始化 SQLite 空库(架构 §2 状态存储层)
let _ = agentdock_store::Store::open_default();
// Wave 2:初始化编排引擎(适配器目录 + 系统密钥库)
let secrets: Arc<dyn agentdock_secrets::SecretStore> = Arc::new(KeyringSecretStore::new());
let engine = Engine::new(adapters_dir(), secrets);
app.manage(engine);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::env::detect_env,
commands::catalog::list_catalog,
commands::cli::detect_cli,
commands::cli::get_adapter,
commands::cli::preview_action,
commands::cli::run_action,
commands::cli::read_config,
commands::cli::write_config,
commands::cli::auth_status,
commands::cli::diagnose,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+21 -9
View File
@@ -7,6 +7,7 @@ import { MyCliPage } from "./pages/MyCli";
import { ConfigCenterPage } from "./pages/ConfigCenter";
import { BackupPage } from "./pages/Backup";
import { SettingsPage } from "./pages/Settings";
import { CliDetailPage } from "./pages/CliDetail";
import { useEnv } from "./hooks/useEnv";
import type { PlatformEnv, RuntimeInfo } from "./ipc/types";
@@ -27,34 +28,45 @@ function envWarningCount(env: PlatformEnv | null): number {
export default function App() {
const [page, setPage] = useState<PageKey>("overview");
const [detailCliId, setDetailCliId] = useState<string | null>(null);
const { env } = useEnv();
const warningCount = envWarningCount(env);
function navigate(next: PageKey) {
setDetailCliId(null);
setPage(next);
}
const content = useMemo(() => {
if (detailCliId) {
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} />;
}
switch (page) {
case "overview":
return <OverviewPage onNavigate={setPage} />;
return <OverviewPage onNavigate={navigate} />;
case "catalog":
return <CatalogPage />;
return <CatalogPage onOpenDetail={setDetailCliId} />;
case "my-cli":
return <MyCliPage onNavigate={setPage} />;
return <MyCliPage onNavigate={navigate} />;
case "config":
return <ConfigCenterPage onNavigate={setPage} />;
return <ConfigCenterPage onNavigate={navigate} />;
case "backup":
return <BackupPage />;
case "settings":
return <SettingsPage />;
}
}, [page]);
}, [page, detailCliId, env]);
const title = detailCliId ? "CLI 详情" : PAGE_META[page].title;
return (
<div className="app-shell">
<Sidebar current={page} onNavigate={setPage} />
<Sidebar current={page} onNavigate={navigate} />
<div className="app-main">
<Header title={PAGE_META[page].title} warningCount={warningCount} />
<Header title={title} warningCount={warningCount} />
<main className="app-content">
{/* key=page 触发 §4.2 页面切换动效:translateY(8px)+淡入 */}
<div key={page} className="page-enter">
{/* key 触发 §4.2 页面切换动效:translateY(8px)+淡入 */}
<div key={detailCliId ?? page} className="page-enter">
{content}
</div>
</main>
+205
View File
@@ -0,0 +1,205 @@
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 { MonoChip } from "./MonoChip";
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) */
export function ConfigForm({ id }: { id: string }) {
const [state, setState] = useState<ConfigFormState | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [result, setResult] = useState<WriteResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
readConfig(id)
.then((s) => {
if (cancelled) return;
setState(s);
const init: Record<string, string> = {};
for (const f of s.fields) {
if (!f.sensitive && f.value != null) init[f.id] = f.value;
}
setValues(init);
setLoading(false);
})
.catch((e) => {
if (!cancelled) {
setError(String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [id]);
const sensitiveSaved = useMemo(() => {
const m: Record<string, boolean> = {};
state?.fields.forEach((f) => {
if (f.sensitive) m[f.id] = f.has_value;
});
return m;
}, [state]);
if (loading) return <p className="panel-empty"></p>;
if (error) return <p className="panel-empty">{error}</p>;
if (!state) return null;
const hasFields = state.fields.length > 0;
async function onSave() {
setSaving(true);
setSaved(false);
setResult(null);
setError(null);
const patch: Record<string, string> = {};
for (const f of state!.fields) {
const v = values[f.id];
if (v != null && v !== "") {
patch[f.id] = v;
}
}
if (Object.keys(patch).length === 0) {
setError("没有需要保存的改动");
setSaving(false);
return;
}
try {
const r = await writeConfig(id, patch);
setResult(r);
setSaved(true);
setValues({});
// 刷新回显
const s = await readConfig(id);
setState(s);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
return (
<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 }))}
/>
))}
{state.files.length > 0 && (
<div className="config-file-info">
<span className="config-file-label"></span>
{state.files.map((f) => (
<span key={f.path} className="config-file-path">
<MonoChip>{f.path}</MonoChip>
{f.exists ? (f.parse_ok ? " · 已解析" : " · 解析异常") : " · 尚未创建"}
</span>
))}
</div>
)}
{hasFields && (
<div className="config-savebar">
<div className="config-save-msg">
{saved && (
<span className="save-ok">
<Check size={14} strokeWidth={1.5} aria-hidden="true" />
</span>
)}
{result?.backup_path && (
<span className="save-detail">
· {result.backup_path.split(/[\\/]/).pop()}
</span>
)}
{error && <span className="save-error">{error}</span>}
</div>
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={saving}
>
{saving ? "保存中…" : "保存配置"}
</button>
</div>
)}
</div>
);
}
function Field({
field,
value,
saved,
revealed,
onChange,
onToggleReveal,
}: {
field: ConfigFieldState;
value: string;
saved: boolean;
revealed: boolean;
onChange: (v: string) => void;
onToggleReveal: () => void;
}) {
const isPassword = field.sensitive;
const inputType = isPassword ? (revealed ? "text" : "password") : field.field_type === "url" ? "text" : "text";
return (
<div className="field">
<label className="field-label" htmlFor={`field-${field.id}`}>
{field.label_zh}
{field.required && <span className="field-required"> *</span>}
</label>
<div className="field-control">
{isPassword && saved && (
<span className="field-lock" title="已加密保存于系统密钥库">
<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)}
/>
{isPassword && (
<button
type="button"
className="field-eye"
onClick={onToggleReveal}
aria-label={revealed ? "隐藏" : "显示"}
tabIndex={-1}
>
{revealed ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
)}
</div>
<div className="field-help">
{field.help_zh}
{isPassword && (
<span className="field-keyring-note">
{saved ? " · 已加密保存于系统密钥库" : " · 保存后写入系统密钥库,绝不明文落盘"}
</span>
)}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
interface ModalProps {
title: string;
onClose?: () => void;
children: ReactNode;
footer?: ReactNode;
}
/** 弹窗(视觉规范 §3.6--ad-bg-2 底 + --ad-radius-l + --ad-shadow-pop + 遮罩) */
export function Modal({ title, onClose, children, footer }: ModalProps) {
return (
<div className="modal-mask" role="dialog" aria-modal="true" aria-label={title}>
<div className="modal">
<div className="modal-head">
<h3 className="modal-title">{title}</h3>
{onClose && (
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭">
<X size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
import { authStatus, detectCli, getAdapter } from "../ipc";
import type { Adapter, AuthStatus, DetectResult } from "../ipc/types";
export interface UseCliDetailResult {
adapter: Adapter | null;
detect: DetectResult | null;
auth: AuthStatus | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
/** 加载单个 CLI 的适配器定义 + 检测结果 + 授权状态(CLI 详情页) */
export function useCliDetail(id: string): UseCliDetailResult {
const [adapter, setAdapter] = useState<Adapter | null>(null);
const [detect, setDetect] = useState<DetectResult | null>(null);
const [auth, setAuth] = useState<AuthStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const [a, d, s] = await Promise.all([getAdapter(id), detectCli(id), authStatus(id)]);
setAdapter(a);
setDetect(d);
setAuth(s);
setError(null);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
setLoading(true);
void refresh();
}, [refresh]);
return { adapter, detect, auth, loading, error, refresh };
}
+85 -7
View File
@@ -1,6 +1,19 @@
// 类型化 invoke 封装(唯一前端 → Rust 入口,架构 §2「界面层禁直接拼命令」)
import { invoke } from "@tauri-apps/api/core";
import type { CatalogEntry, PlatformEnv } from "./types";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
Adapter,
AuthStatus,
CatalogEntry,
CliAction,
CliActionEvent,
ConfigFormState,
DetectResult,
DiagnosticReport,
DryRunPlan,
PlatformEnv,
WriteResult,
} from "./types";
/** 是否运行在 Tauri 环境(否则走 mock,便于纯浏览器联调 UI) */
function isTauri(): boolean {
@@ -8,20 +21,68 @@ function isTauri(): boolean {
}
export async function detectEnv(): Promise<PlatformEnv> {
if (!isTauri()) {
return mockPlatformEnv();
}
if (!isTauri()) return mockPlatformEnv();
return invoke<PlatformEnv>("detectEnv");
}
export async function listCatalog(): Promise<CatalogEntry[]> {
if (!isTauri()) {
return mockCatalog();
}
if (!isTauri()) return mockCatalog();
return invoke<CatalogEntry[]>("listCatalog");
}
// ---- Wave 2CLI 全链路 ----
export async function getAdapter(id: string): Promise<Adapter> {
return invoke<Adapter>("getAdapter", { id });
}
export async function detectCli(id: string): Promise<DetectResult> {
if (!isTauri()) return mockDetect(id);
return invoke<DetectResult>("detectCli", { id });
}
export async function previewAction(
id: string,
action: CliAction,
channel?: string,
): Promise<DryRunPlan> {
return invoke<DryRunPlan>("previewAction", { id, action, channel });
}
export async function runAction(id: string, action: CliAction, channel?: string): Promise<void> {
return invoke<void>("runAction", { id, action, channel });
}
export async function readConfig(id: string): Promise<ConfigFormState> {
return invoke<ConfigFormState>("readConfig", { id });
}
export async function writeConfig(
id: string,
patch: Record<string, string>,
): Promise<WriteResult> {
return invoke<WriteResult>("writeConfig", { id, patch });
}
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> {
return invoke<DiagnosticReport>("diagnose", { id });
}
/** 订阅 runAction 的流式事件(cli-action-event)。返回取消订阅函数。 */
export async function onCliAction(
handler: (payload: CliActionEvent) => void,
): Promise<UnlistenFn> {
if (!isTauri()) return () => {};
return listen<CliActionEvent>("cli-action-event", (e) => handler(e.payload));
}
// ---- 浏览器联调用 mock(仅当不在 Tauri 内时生效,不影响真实数据) ----
const mockCatalog = (): CatalogEntry[] => [
{ id: "codex", name: "Codex CLI", name_zh: "Codex CLI", vendor: "OpenAI", status: "available" },
{ id: "claude-code", name: "Claude Code", name_zh: "Claude Code", vendor: "Anthropic", status: "available" },
@@ -39,6 +100,23 @@ const mockCatalog = (): CatalogEntry[] => [
{ id: "warp", name: "Warp Agent CLI", name_zh: "Warp Agent CLI", vendor: "Warp", status: "available" },
];
const mockDetect = (id: string): DetectResult => ({
cli_id: id,
status: "not_installed",
version: null,
executable: null,
version_unconfirmed: false,
checked_at: Math.floor(Date.now() / 1000),
});
const mockAuth = (id: string): AuthStatus => ({
cli_id: id,
status: "unknown",
via: "unknown",
detail_zh: "尚未检测到授权信息",
checked_at: Math.floor(Date.now() / 1000),
});
const mockPlatformEnv = (): PlatformEnv => ({
os: "windows",
os_version: "Windows 11 24H2 (Build 26100)",
+175 -1
View File
@@ -1,4 +1,4 @@
// IPC 契约类型(对齐架构 §5 PlatformEnv / listCatalog
// IPC 契约类型(对齐架构 §2「关键 IPC 契约」与 core/types.rs
// 字段命名与 Rust 侧 serde 输出一致(snake_case)。
export interface PlatformEnv {
@@ -55,3 +55,177 @@ export interface CatalogEntry {
vendor: string;
status: string;
}
// ---- Wave 2CLI 全链路契约 ----
export type CliAction = "install" | "update" | "uninstall" | "write_config" | "authorize" | "repair";
export type DetectStatus =
| "installed"
| "not_installed"
| "not_in_path"
| "permission_denied"
| "exec_failed"
| "version_unparseable";
export interface DetectResult {
cli_id: string;
status: DetectStatus;
version: string | null;
executable: string | null;
version_unconfirmed: boolean;
checked_at: number;
}
export type AuthState = "authorized" | "unauthorized" | "unknown" | "possibly_expired";
export interface AuthStatus {
cli_id: string;
status: AuthState;
via: string;
detail_zh: string;
checked_at: number;
}
export interface ConfigFieldState {
id: string;
label_zh: string;
help_zh: string | null;
required: boolean;
sensitive: boolean;
field_type: string;
storage: string;
value: string | null;
has_value: boolean;
}
export interface ConfigFileState {
path: string;
format: string;
scope: string | null;
exists: boolean;
parse_ok: boolean;
error: string | null;
}
export interface EnvFieldState {
key: string;
sensitive: boolean;
maps_to_field: string | null;
}
export interface ConfigFormState {
cli_id: string;
files: ConfigFileState[];
fields: ConfigFieldState[];
environment: EnvFieldState[];
}
export interface WriteResult {
backup_path: string | null;
written_file: string | null;
written_fields: string[];
errors: string[];
}
export interface DryRunPlan {
commands: string[][];
elevate: boolean;
elevate_reason_zh: string | null;
affected_files: string[];
rollback_zh: string;
}
export type ActionEventKind = "step" | "stdout" | "stderr" | "done" | "error";
export interface ActionEvent {
kind: ActionEventKind;
message: string;
data: unknown;
}
export interface CliActionEvent {
cli_id: string;
event: ActionEvent;
}
export interface DiagnosticFinding {
rule_id: string;
severity: "info" | "warn" | "error";
message_zh: string;
evidence: string | null;
}
export interface DiagnosticReport {
cli_id: string;
findings: DiagnosticFinding[];
}
// ---- 适配器完整定义(getAdapter 返回,detail 页展示用) ----
export interface AdapterChannel {
id: string;
platforms?: string[];
command?: string[];
script?: { url?: string | null; kind?: string | null } | null;
package?: string | null;
elevate?: string | null;
elevate_reason_zh?: string | null;
post_checks?: string[];
}
export interface Adapter {
id: string;
name: string;
name_zh: string;
vendor: string;
status: string;
adapter_version?: string | null;
license?: string | null;
platforms?: {
windows?: { architectures?: string[]; notes?: string | null } | null;
linux?: { distributions?: string[]; architectures?: string[]; min_ubuntu?: string | null } | null;
} | null;
official?: { homepage?: string | null; docs?: string | null; allowed_hosts?: string[] } | null;
runtime_deps?: { id: string; semver_range?: string | null; required_for?: string[] }[];
install?: { preferred?: string | null; channels: AdapterChannel[] } | null;
detect?: {
executable: string;
version_args?: string[];
version_regex?: string | null;
version_unconfirmed?: boolean | null;
path_hints?: string[];
} | null;
update?: { method?: string | null; command?: string[] } | null;
uninstall?: { method?: string | null; command?: string[]; keep_config_default?: boolean } | null;
authorization?: {
modes: {
mode: string;
command?: string[];
env_keys?: string[];
status_command?: string[];
notes_zh?: string | null;
}[];
} | null;
configuration?: {
files: { path: string; format: string; scope?: string | null }[];
environment?: { key: string; sensitive?: boolean; maps_to_field?: string | null }[];
fields?: {
id: string;
label_zh: string;
help_zh?: string | null;
required?: boolean;
sensitive?: boolean;
type: string;
storage: string;
platforms?: string[];
docs_url?: string | null;
}[];
} | null;
documentation?: {
quickstart_zh?: string | null;
commands?: { cmd: string; desc_zh?: string | null }[];
updated_at?: string | null;
risks_zh?: string[];
} | null;
}
+14 -2
View File
@@ -21,7 +21,7 @@ const PLATFORM_FILTERS: { key: PlatformFilter; label: string }[] = [
];
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml */
export function CatalogPage() {
export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) {
const { entries, loading, error } = useCatalog();
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
@@ -87,7 +87,19 @@ export function CatalogPage() {
<div className="catalog-grid">
{filtered.map((entry) => (
<article className="catalog-card" key={entry.id}>
<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">
+490
View File
@@ -0,0 +1,490 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
BookOpen,
ChevronDown,
CircleHelp,
ClipboardList,
Loader2,
ShieldAlert,
Terminal,
Trash2,
} from "lucide-react";
import { useCliDetail } from "../hooks/useCliDetail";
import { diagnose, onCliAction, previewAction, runAction } from "../ipc";
import type {
ActionEvent,
Adapter,
AuthStatus,
CliAction,
DetectResult,
DiagnosticReport,
DryRunPlan,
} from "../ipc/types";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
import { Modal } from "../components/Modal";
import { ConfigForm } from "../components/ConfigForm";
type Tab = "overview" | "config" | "docs" | "diag";
const TABS: { key: Tab; label: string }[] = [
{ key: "overview", label: "概览" },
{ key: "config", label: "配置" },
{ key: "docs", label: "中文文档" },
{ key: "diag", label: "诊断" },
];
/** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */
function AuthLight({ auth }: { auth: AuthStatus | null }) {
if (!auth) return <span className="status-dot unknown" aria-hidden="true" />;
const cls =
auth.status === "authorized"
? "status-dot ok"
: auth.status === "unauthorized"
? "status-dot auth"
: auth.status === "possibly_expired"
? "status-dot warn breathe"
: "status-dot unknown";
const label =
auth.status === "authorized"
? "已授权"
: auth.status === "unauthorized"
? "未授权"
: auth.status === "possibly_expired"
? "授权可能过期"
: "未知";
return (
<span className="auth-light">
<span className={cls} aria-hidden="true" />
<span className="auth-light-text">{label}</span>
</span>
);
}
/** 检测状态中文 */
function detectLabel(d: DetectResult | null): string {
if (!d) return "未检测";
switch (d.status) {
case "installed":
return "已安装";
case "not_installed":
return "未安装";
case "not_in_path":
return "不在 PATH";
case "version_unparseable":
return "版本未知";
case "permission_denied":
return "无访问权限";
case "exec_failed":
return "执行失败";
default:
return d.status;
}
}
export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }) {
const { adapter, detect, auth, loading, error, refresh } = useCliDetail(id);
const [tab, setTab] = useState<Tab>("overview");
const [confirm, setConfirm] = useState<{ action: CliAction; plan: DryRunPlan } | null>(null);
const [running, setRunning] = useState(false);
const [runTitle, setRunTitle] = useState("");
const [log, setLog] = useState<ActionEvent[]>([]);
const logEndRef = useRef<HTMLDivElement | null>(null);
const installed = detect?.status === "installed";
// 订阅流式事件
useEffect(() => {
let unlisten: (() => void) | undefined;
onCliAction((payload) => {
if (payload.cli_id !== id) return;
const ev = payload.event;
if (ev.kind === "done") {
setRunning(false);
void refresh();
return;
}
if (ev.kind === "error") {
setRunning(false);
setLog((l) => [...l, { kind: "error", message: ev.message, data: null }]);
return;
}
setLog((l) => [...l, ev]);
}).then((fn) => {
unlisten = fn;
});
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 });
}
async function confirmRun() {
if (!confirm) return;
const action = confirm.action;
setConfirm(null);
setRunning(true);
setLog([]);
setRunTitle(action === "install" ? "正在安装" : action === "uninstall" ? "正在卸载" : "正在执行");
await runAction(id, action);
}
if (loading) {
return <p className="panel-empty"></p>;
}
if (error) {
return (
<div className="cli-detail">
<p className="panel-empty">{error}</p>
</div>
);
}
if (!adapter) return null;
return (
<div className="cli-detail">
<button type="button" className="link-btn detail-back" onClick={onBack}>
<ArrowLeft size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
{/* 头卡(§3.3 */}
<section className="panel cli-head">
<div className="cli-head-main">
<CliMonogram id={adapter.id} name={adapter.name} size={56} />
<div className="cli-head-info">
<div className="cli-head-name">{adapter.name_zh}</div>
<div className="cli-head-vendor">
{adapter.vendor}
{adapter.license && <span className="cli-head-license"> · {adapter.license}</span>}
</div>
<div className="cli-head-meta">
<span className="status-badge-mini">{detectLabel(detect)}</span>
{detect?.version && <MonoChip>{detect.version}</MonoChip>}
{detect?.executable && (
<span className="cli-head-path">
<MonoChip>{detect.executable}</MonoChip>
</span>
)}
{detect?.version_unconfirmed && (
<span className="cli-head-unconfirmed"></span>
)}
</div>
</div>
</div>
<div className="cli-head-side">
<AuthLight auth={auth} />
<div className="cli-head-actions">
{!installed ? (
<button type="button" className="btn btn-primary" onClick={() => openConfirm("install")}>
</button>
) : (
<button type="button" className="btn btn-secondary" onClick={() => setTab("config")}>
</button>
)}
<button
type="button"
className="btn btn-text-danger"
disabled={!installed}
title={!installed ? "尚未安装" : "卸载(默认保留配置)"}
onClick={() => openConfirm("uninstall")}
>
<Trash2 size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
</div>
</section>
{/* 标签页 */}
<nav className="tabs" role="tablist" aria-label="CLI 详情标签页">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={tab === t.key ? "tab active" : "tab"}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</nav>
<section className="cli-detail-body">
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} />}
{tab === "config" && <ConfigForm id={id} />}
{tab === "docs" && <DocsTab adapter={adapter} />}
{tab === "diag" && <DiagTab id={id} />}
</section>
{/* 安装/卸载确认弹窗(§3.3:完整展示命令/权限/影响,确认才执行) */}
{confirm && (
<Modal
title={confirm.action === "install" ? `确认安装 ${adapter.name_zh}` : `确认卸载 ${adapter.name_zh}`}
onClose={() => setConfirm(null)}
footer={
<>
<button type="button" className="btn btn-secondary" onClick={() => setConfirm(null)}>
</button>
<button type="button" className="btn btn-primary" onClick={confirmRun}>
{confirm.action === "install" ? "安装" : "卸载"}
</button>
</>
}
>
<ConfirmBody plan={confirm.plan} action={confirm.action} />
</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>
</Modal>
)}
</div>
);
}
function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction }) {
return (
<div className="confirm-body">
<div className="confirm-section">
<div className="confirm-label"></div>
{plan.commands.length === 0 ? (
<p className="confirm-empty"></p>
) : (
plan.commands.map((cmd, i) => (
<div key={i} className="confirm-cmd">
<MonoChip>{cmd.join(" ")}</MonoChip>
</div>
))
)}
</div>
<div className="confirm-section">
<div className="confirm-label"></div>
<p className="confirm-text">
{plan.elevate ? (
<>{plan.elevate_reason_zh ? `${plan.elevate_reason_zh}` : ""}</>
) : (
"无需管理员权限(安装到用户目录)"
)}
</p>
</div>
{plan.affected_files.length > 0 && (
<div className="confirm-section">
<div className="confirm-label"></div>
<div className="confirm-files">
{plan.affected_files.map((f) => (
<span key={f} className="confirm-file">
<MonoChip>{f}</MonoChip>
</span>
))}
</div>
</div>
)}
<div className="confirm-section">
<div className="confirm-label"></div>
<p className="confirm-text">{plan.rollback_zh}</p>
</div>
{action === "uninstall" && (
<p className="confirm-note"></p>
)}
</div>
);
}
function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResult | null }) {
const channels = adapter.install?.channels ?? [];
return (
<div className="detail-overview">
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">
{adapter.documentation?.quickstart_zh ?? "参见官方文档。"}
</p>
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
{channels.length === 0 ? (
<p className="detail-block-text"></p>
) : (
<ul className="detail-channels">
{channels.map((c) => (
<li key={c.id} className="detail-channel">
<MonoChip>{c.id}</MonoChip>
{c.command && c.command.length > 0 && <MonoChip>{c.command.join(" ")}</MonoChip>}
{c.package && <span className="detail-channel-pkg"> {c.package}</span>}
</li>
))}
</ul>
)}
</div>
{detect && detect.status !== "installed" && detect.status !== "not_installed" && (
<div className="detail-block detail-note">
<CircleHelp size={14} strokeWidth={1.5} aria-hidden="true" />
{detectLabel(detect)} PATH
</div>
)}
</div>
);
}
function DocsTab({ adapter }: { adapter: Adapter }) {
const doc = adapter.documentation;
return (
<div className="detail-docs">
<div className="detail-block">
<h3 className="detail-block-title">
<BookOpen size={14} strokeWidth={1.5} aria-hidden="true" />
</h3>
{(doc?.commands ?? []).length === 0 ? (
<p className="detail-block-text"></p>
) : (
<ul className="detail-commands">
{doc!.commands!.map((c) => (
<li key={c.cmd} className="detail-command">
<MonoChip>{c.cmd}</MonoChip>
{c.desc_zh && <span className="detail-command-desc">{c.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" />
<div>
{doc!.risks_zh!.map((r) => (
<p key={r}>{r}</p>
))}
</div>
</div>
)}
{adapter.official?.homepage && (
<div className="detail-block detail-block-text">
<MonoChip>{adapter.official.homepage}</MonoChip>
</div>
)}
</div>
);
}
function severityMeta(sev: "info" | "warn" | "error") {
switch (sev) {
case "info":
return { cls: "diag-sev-info", label: "提示" };
case "warn":
return { cls: "diag-sev-warn", label: "警告" };
case "error":
return { cls: "diag-sev-error", label: "错误" };
}
}
function DiagTab({ id }: { id: string }) {
const [report, setReport] = useState<DiagnosticReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState<Record<number, boolean>>({});
useEffect(() => {
let cancelled = false;
diagnose(id)
.then((r) => {
if (!cancelled) {
setReport(r);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [id]);
if (loading) return <p className="panel-empty"></p>;
if (error) return <p className="panel-empty">{error}</p>;
if (!report) return null;
const findings = useMemo(() => report.findings, [report]);
return (
<div className="diag-result">
<div className="diag-summary">
{findings.length === 0 ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
</>
) : (
<>
<span className={`status-dot ${findings.some((f) => f.severity === "error") ? "error" : "warn"}`} aria-hidden="true" />
<span>
{findings.filter((f) => f.severity === "error").length} {" "}
{findings.filter((f) => f.severity === "warn").length} {" "}
{findings.filter((f) => f.severity === "info").length}
</span>
</>
)}
</div>
{findings.map((f, i) => {
const meta = severityMeta(f.severity);
return (
<div key={i} className="diag-finding">
<div className={`diag-sev-bar ${meta.cls}`} aria-hidden="true" />
<div className="diag-finding-body">
<div className="diag-finding-head">
<span className={`diag-sev-tag ${meta.cls}`}>{meta.label}</span>
<span className="diag-finding-rule">{f.rule_id}</span>
</div>
<p className="diag-finding-msg">{f.message_zh}</p>
{f.evidence && (
<>
<button
type="button"
className="link-btn diag-evidence-toggle"
onClick={() => setOpen((o) => ({ ...o, [i]: !o[i] }))}
>
<ChevronDown size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
{open[i] && <pre className="diag-evidence">{f.evidence}</pre>}
</>
)}
</div>
</div>
);
})}
</div>
);
}
+642
View File
@@ -1173,3 +1173,645 @@ button {
[data-fx-tier="low"] .kpi-card:hover {
transform: none;
}
/* ============================================================
* Wave 2CLI 详情页 / 安装确认弹窗 / 配置表单 / 诊断结果
* 全部颜色/辉光/时长来自 tokens/(视觉规范 v1.2 §3.33.5 + v1.3
* ============================================================ */
/* 授权状态点(未授权 = 紫,静态,不发光不呼吸,§3.3) */
.status-dot.auth {
background: var(--ad-attention);
}
/* ---------- CLI 详情页 ---------- */
.cli-detail {
max-width: var(--ad-frame-max);
margin: 0 auto;
}
.detail-back {
margin-bottom: var(--ad-space-4);
}
.cli-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--ad-space-6);
padding: var(--ad-space-6);
margin-bottom: var(--ad-space-5);
}
.cli-head-main {
display: flex;
align-items: flex-start;
gap: var(--ad-space-4);
min-width: 0;
}
.cli-head-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.cli-head-name {
font-size: var(--ad-text-xl-size);
font-weight: var(--ad-text-xl-weight);
}
.cli-head-vendor {
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
.cli-head-license {
color: var(--ad-text-3);
}
.cli-head-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ad-space-2);
margin-top: var(--ad-space-1);
}
.status-badge-mini {
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.cli-head-path {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cli-head-unconfirmed {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
.cli-head-side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--ad-space-3);
flex-shrink: 0;
}
.auth-light {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
}
.auth-light-text {
color: var(--ad-text-1);
font-size: var(--ad-text-s-size);
}
.cli-head-actions {
display: flex;
align-items: center;
gap: var(--ad-space-3);
}
/* 危险文字按钮(§3.3:卸载 = 文字按钮,悬停变红) */
.btn-text-danger {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
height: 36px;
padding: 0 var(--ad-space-2);
border: none;
background: none;
color: var(--ad-text-2);
font-size: var(--ad-text-m-size);
cursor: pointer;
transition: color var(--ad-dur-fast) var(--ad-ease-out);
}
.btn-text-danger:hover:not(:disabled) {
color: var(--ad-danger);
}
.btn-text-danger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- 标签页(§3.3:底部 2px 青条 + 青色文字) ---------- */
.tabs {
display: flex;
align-items: center;
gap: var(--ad-space-5);
border-bottom: 1px solid var(--ad-border);
margin-bottom: var(--ad-space-5);
}
.tab {
position: relative;
height: 40px;
padding: 0 var(--ad-space-2);
border: none;
background: none;
color: var(--ad-text-2);
font-size: var(--ad-text-m-size);
cursor: pointer;
transition: color var(--ad-dur-fast) var(--ad-ease-out);
}
.tab:hover {
color: var(--ad-text-1);
}
.tab.active {
color: var(--ad-primary);
}
.tab.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--ad-primary);
}
.cli-detail-body {
max-width: 720px;
}
/* ---------- 概览 / 文档块 ---------- */
.detail-block {
margin-bottom: var(--ad-space-5);
}
.detail-block-title {
display: flex;
align-items: center;
gap: var(--ad-space-2);
font-size: var(--ad-text-l-size);
font-weight: var(--ad-text-l-weight);
margin-bottom: var(--ad-space-3);
}
.detail-block-text {
color: var(--ad-text-2);
font-size: var(--ad-text-m-size);
line-height: 1.6;
}
.detail-channels {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.detail-channel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ad-space-2);
}
.detail-channel-pkg {
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.detail-note {
display: flex;
align-items: flex-start;
gap: var(--ad-space-2);
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
padding: var(--ad-space-4);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
background: var(--ad-bg-1);
}
.detail-commands {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--ad-space-3);
}
.detail-command {
display: flex;
align-items: center;
gap: var(--ad-space-3);
}
.detail-command-desc {
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
/* ---------- 诊断结果(§3.5 ---------- */
.diag-summary {
display: flex;
align-items: center;
gap: var(--ad-space-3);
padding: var(--ad-space-4);
margin-bottom: var(--ad-space-4);
background: var(--ad-bg-1);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
font-size: var(--ad-text-m-size);
}
.diag-finding {
display: flex;
align-items: stretch;
margin-bottom: var(--ad-space-3);
background: var(--ad-bg-1);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
overflow: hidden;
}
.diag-sev-bar {
width: 3px;
flex-shrink: 0;
}
.diag-sev-bar.diag-sev-info,
.diag-sev-tag.diag-sev-info {
background: var(--ad-attention);
color: var(--ad-attention);
}
.diag-sev-bar.diag-sev-warn,
.diag-sev-tag.diag-sev-warn {
background: var(--ad-warning);
color: var(--ad-warning);
}
.diag-sev-bar.diag-sev-error,
.diag-sev-tag.diag-sev-error {
background: var(--ad-danger);
color: var(--ad-danger);
}
.diag-finding-body {
flex: 1;
padding: var(--ad-space-4);
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.diag-finding-head {
display: flex;
align-items: center;
gap: var(--ad-space-2);
}
.diag-sev-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 var(--ad-space-2);
border-radius: var(--ad-radius-s);
font-size: var(--ad-text-xs-size);
background: transparent;
}
.diag-finding-rule {
color: var(--ad-text-3);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
}
.diag-finding-msg {
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
line-height: 1.5;
}
.diag-evidence-toggle {
align-self: flex-start;
}
.diag-evidence {
padding: var(--ad-space-3);
background: var(--ad-bg-0);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-s);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
color: var(--ad-text-2);
white-space: pre-wrap;
word-break: break-all;
}
/* ---------- 弹窗(§3.6 ---------- */
.modal-mask {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: var(--ad-modal-mask);
}
.modal {
width: min(560px, calc(100vw - 48px));
max-height: calc(100vh - 96px);
display: flex;
flex-direction: column;
background: var(--ad-bg-2);
border-radius: var(--ad-radius-l);
box-shadow: var(--ad-shadow-pop);
border: 1px solid var(--ad-border);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--ad-space-4) var(--ad-space-5);
border-bottom: 1px solid var(--ad-border);
}
.modal-title {
font-size: var(--ad-text-l-size);
font-weight: var(--ad-text-l-weight);
}
.modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
color: var(--ad-text-2);
cursor: pointer;
border-radius: var(--ad-radius-s);
}
.modal-close:hover {
color: var(--ad-text-1);
background: var(--ad-bg-3);
}
.modal-body {
padding: var(--ad-space-5);
overflow-y: auto;
}
.modal-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--ad-space-3);
padding: var(--ad-space-4) var(--ad-space-5);
border-top: 1px solid var(--ad-border);
}
/* ---------- 确认弹窗内容 ---------- */
.confirm-body {
display: flex;
flex-direction: column;
gap: var(--ad-space-4);
}
.confirm-section {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.confirm-label {
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.confirm-text {
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
}
.confirm-empty {
color: var(--ad-text-3);
font-size: var(--ad-text-s-size);
}
.confirm-cmd {
padding: var(--ad-space-3);
background: var(--ad-bg-0);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-s);
overflow-x: auto;
}
.confirm-files {
display: flex;
flex-wrap: wrap;
gap: var(--ad-space-2);
}
.confirm-note {
color: var(--ad-warning);
font-size: var(--ad-text-xs-size);
}
/* ---------- 流式执行日志 ---------- */
.run-log {
display: flex;
flex-direction: column;
gap: var(--ad-space-1);
max-height: 320px;
overflow-y: auto;
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
}
.run-log-wait {
display: flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-text-2);
}
.run-log-line {
display: flex;
align-items: flex-start;
gap: var(--ad-space-2);
color: var(--ad-text-2);
word-break: break-all;
white-space: pre-wrap;
}
.run-log-line.step {
color: var(--ad-primary);
}
.run-log-line.stderr {
color: var(--ad-warning);
}
.run-log-line.error {
color: var(--ad-danger);
}
.spin {
animation: ad-spin 1.2s linear infinite;
}
@keyframes ad-spin {
to {
transform: rotate(360deg);
}
}
/* ---------- 配置表单(§3.4 ---------- */
.config-form {
display: flex;
flex-direction: column;
gap: var(--ad-space-5);
max-width: 640px;
}
.field {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.field-label {
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
.field-required {
color: var(--ad-primary);
}
.field-control {
position: relative;
display: flex;
align-items: center;
}
.field-control input {
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;
transition: border-color var(--ad-dur-micro) var(--ad-ease-out),
box-shadow var(--ad-dur-micro) var(--ad-ease-out);
}
.field-control input::placeholder {
color: var(--ad-text-3);
}
.field-control input:focus {
border-color: var(--ad-primary);
box-shadow: var(--ad-glow-primary);
}
.field-control input[type="password"] {
padding-right: 40px;
}
.field-lock {
position: absolute;
left: var(--ad-space-3);
color: var(--ad-text-2);
pointer-events: none;
}
.field-control .field-lock + input {
padding-left: 32px;
}
.field-eye {
position: absolute;
right: var(--ad-space-2);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
color: var(--ad-text-2);
cursor: pointer;
}
.field-eye:hover {
color: var(--ad-text-1);
}
.field-help {
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.field-keyring-note {
color: var(--ad-attention);
}
.config-file-info {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ad-space-2);
padding-top: var(--ad-space-4);
border-top: 1px solid var(--ad-border);
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.config-savebar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ad-space-4);
padding: var(--ad-space-4) 0;
background: linear-gradient(180deg, transparent, var(--ad-bg-0) 40%);
}
.config-save-msg {
display: flex;
flex-direction: column;
gap: var(--ad-space-1);
font-size: var(--ad-text-xs-size);
}
.save-ok {
display: inline-flex;
align-items: center;
gap: var(--ad-space-1);
color: var(--ad-success);
}
.save-detail {
color: var(--ad-text-2);
}
.save-error {
color: var(--ad-danger);
}