Wave 2.1: 返工黑屏/安装进度/官方配置三层/文档深度/真机列表
老板实测 5 条修复:DiagTab Hooks 黑屏、安装三阶段进度、配置按官方字段三层分组、FR-09 中文文档、detectCliAll 接入列表页。
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
/// 批量检测全部 CLI(detectCliAll),供总览/目录/我的 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> {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CliAction,
|
||||
CliActionEvent,
|
||||
ConfigFormState,
|
||||
ConfigVerifyResult,
|
||||
DetectResult,
|
||||
DiagnosticReport,
|
||||
DryRunPlan,
|
||||
@@ -33,6 +34,7 @@ export async function listCatalog(): Promise<CatalogEntry[]> {
|
||||
// ---- Wave 2:CLI 全链路 ----
|
||||
|
||||
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" ? "可执行卸载命令回退;配置文件默认保留。" : "卸载默认保留配置,可重新安装恢复。",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 不看 apt,Linux 不看 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user