Wave 2.2: 流式安装输出、软件内授权四模式、本机环境一键装与总览缓存
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
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, ConfigVerifyResult, DetectResult, Engine, WriteResult};
|
||||
use agentdock_core::{ActionEvent, ActionOpts, AuthFlowEvent, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult};
|
||||
use agentdock_diag::DiagnosticReport;
|
||||
use serde_json::json;
|
||||
use tauri::Emitter;
|
||||
@@ -125,9 +125,44 @@ pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthSt
|
||||
state.auth_status(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 软件内授权(authorize):按官方授权方式运行登录命令,事件经 `cli-auth-event` 流式回传。
|
||||
#[tauri::command(rename = "authorize")]
|
||||
pub fn authorize(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
mode: String,
|
||||
state: tauri::State<'_, Engine>,
|
||||
) -> Result<(), String> {
|
||||
let engine = state.inner().clone();
|
||||
std::thread::spawn(move || {
|
||||
let cli_id = id.clone();
|
||||
let m = mode.clone();
|
||||
let result = engine.authorize_stream(&id, &mode, |ev| {
|
||||
let _ = app.emit("cli-auth-event", &ev);
|
||||
});
|
||||
if let Err(e) = result {
|
||||
let _ = app.emit("cli-auth-event", AuthFlowEvent::error(&cli_id, &m, e.to_string()));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 取消正在进行的授权流程。
|
||||
#[tauri::command(rename = "cancelAuthorize")]
|
||||
pub fn cancel_authorize(id: String, mode: String, state: tauri::State<'_, Engine>) {
|
||||
state.cancel_authorize(&id, &mode);
|
||||
}
|
||||
|
||||
/// 诊断(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())
|
||||
}
|
||||
|
||||
/// 批量诊断全部已装工具(总览「立即诊断」入口用),内部实时取本机环境快照。
|
||||
#[tauri::command(rename = "diagnoseAll")]
|
||||
pub fn diagnose_all(state: tauri::State<'_, Engine>) -> Result<Vec<DiagnosticReport>, String> {
|
||||
let env = agentdock_platform::detect::detect_env();
|
||||
state.diagnose_all(&env).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod env;
|
||||
pub mod runtime;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! IPC 命令:本机环境运行时一键安装(Wave 2.2 Req 3)
|
||||
//!
|
||||
//! 下载仅限官方白名单(`RuntimeSource.allowed_hosts`),下载前经
|
||||
//! `is_url_host_allowed` 校验;下载用系统 `curl.exe`;下载成功后用
|
||||
//! `open_with_shell` 打开安装向导(用户在向导里点完即装)。失败兜底
|
||||
//! 提供「打开官方下载页」。应用本身不提权、不静默安装。
|
||||
|
||||
use agentdock_core::{
|
||||
is_url_host_allowed, open_with_shell, source_for, RuntimeSource,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tauri::Emitter;
|
||||
|
||||
/// 预览运行时安装来源(确认弹窗展示官方地址/体积/权限)。
|
||||
#[tauri::command(rename = "previewRuntimeInstall")]
|
||||
pub fn preview_runtime_install(runtime: String) -> Result<RuntimeSource, String> {
|
||||
source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))
|
||||
}
|
||||
|
||||
/// 兜底:打开运行时官方下载页(默认浏览器)。
|
||||
#[tauri::command(rename = "openRuntimePage")]
|
||||
pub fn open_runtime_page(runtime: String) -> Result<(), String> {
|
||||
let src = source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))?;
|
||||
open_with_shell(&src.download_page).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 一键安装运行时:下载官方安装包 → 打开安装向导。进度经 `runtime-install-event` 回传。
|
||||
#[tauri::command(rename = "installRuntime")]
|
||||
pub fn install_runtime(app: tauri::AppHandle, runtime: String) -> Result<(), String> {
|
||||
let src = source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))?;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let emit = |app: &tauri::AppHandle, payload: serde_json::Value| {
|
||||
let _ = app.emit("runtime-install-event", payload);
|
||||
};
|
||||
|
||||
// 不可直接下载的运行时(如 uv):直接打开官方下载页
|
||||
let Some(url) = src.download_url.clone().filter(|_| src.direct_installable) else {
|
||||
let _ = open_with_shell(&src.download_page);
|
||||
emit(
|
||||
&app,
|
||||
json!({ "runtime": runtime, "phase": "opened_page", "message": format!("已打开{}官方下载页", src.label_zh) }),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// 安全红线:来源必须落白名单
|
||||
emit(&app, json!({ "runtime": runtime, "phase": "validate", "message": format!("正在校验来源({})…", src.source_label) }));
|
||||
if !is_url_host_allowed(&url, &src.allowed_hosts) {
|
||||
emit(
|
||||
&app,
|
||||
json!({ "runtime": runtime, "phase": "error", "message": "下载来源不在官方白名单内,已中止(安全红线)", "fallback": true }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载到临时目录
|
||||
let dir = std::env::temp_dir().join("agentdock-downloads");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let file_name = url.rsplit('/').next().unwrap_or("installer.bin");
|
||||
let dest = dir.join(file_name);
|
||||
emit(&app, json!({ "runtime": runtime, "phase": "download", "message": format!("正在下载 {}({},来自 {})…", src.label_zh, src.size_approx, src.source_label) }));
|
||||
|
||||
match agentdock_core::download_with_curl(&url, &dest) {
|
||||
Ok(()) => {
|
||||
emit(&app, json!({ "runtime": runtime, "phase": "open", "message": "下载完成,正在打开安装向导(请在向导中完成安装)…" }));
|
||||
if let Err(e) = open_with_shell(&dest.to_string_lossy()) {
|
||||
emit(
|
||||
&app,
|
||||
json!({ "runtime": runtime, "phase": "error", "message": format!("无法打开安装向导:{e}"), "fallback": true }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(&app, json!({ "runtime": runtime, "phase": "done", "message": "安装向导已打开" }));
|
||||
}
|
||||
Err(e) => {
|
||||
emit(
|
||||
&app,
|
||||
json!({ "runtime": runtime, "phase": "error", "message": format!("下载失败:{e}。可点击「打开官方下载页」手动下载。"), "fallback": true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -56,7 +56,13 @@ pub fn run() {
|
||||
commands::cli::write_config,
|
||||
commands::cli::verify_config,
|
||||
commands::cli::auth_status,
|
||||
commands::cli::authorize,
|
||||
commands::cli::cancel_authorize,
|
||||
commands::cli::diagnose,
|
||||
commands::cli::diagnose_all,
|
||||
commands::runtime::preview_runtime_install,
|
||||
commands::runtime::open_runtime_page,
|
||||
commands::runtime::install_runtime,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -29,6 +29,7 @@ function envWarningCount(env: PlatformEnv | null): number {
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<PageKey>("overview");
|
||||
const [detailCliId, setDetailCliId] = useState<string | null>(null);
|
||||
const [pendingRuntime, setPendingRuntime] = useState<string | null>(null);
|
||||
const { env } = useEnv();
|
||||
const warningCount = envWarningCount(env);
|
||||
|
||||
@@ -37,13 +38,27 @@ export default function App() {
|
||||
setPage(next);
|
||||
}
|
||||
|
||||
// 从详情页/其它页直达本机环境区某个运行时的一键安装(联动 Wave 2.2 Req 1/3)
|
||||
function openRuntimeInstall(runtime: string) {
|
||||
setDetailCliId(null);
|
||||
setPage("overview");
|
||||
setPendingRuntime(runtime);
|
||||
}
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (detailCliId) {
|
||||
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} />;
|
||||
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} onInstallRuntime={openRuntimeInstall} />;
|
||||
}
|
||||
switch (page) {
|
||||
case "overview":
|
||||
return <OverviewPage onNavigate={navigate} onOpenDetail={setDetailCliId} />;
|
||||
return (
|
||||
<OverviewPage
|
||||
onNavigate={navigate}
|
||||
onOpenDetail={setDetailCliId}
|
||||
pendingRuntime={pendingRuntime}
|
||||
onRuntimeHandled={() => setPendingRuntime(null)}
|
||||
/>
|
||||
);
|
||||
case "catalog":
|
||||
return <CatalogPage onOpenDetail={setDetailCliId} />;
|
||||
case "my-cli":
|
||||
@@ -55,7 +70,7 @@ export default function App() {
|
||||
case "settings":
|
||||
return <SettingsPage />;
|
||||
}
|
||||
}, [page, detailCliId, env]);
|
||||
}, [page, detailCliId, env, pendingRuntime]);
|
||||
|
||||
const title = detailCliId ? "CLI 详情" : PAGE_META[page].title;
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react";
|
||||
import { authorize, cancelAuthorize, onCliAuth } from "../ipc";
|
||||
import type { AuthFlowEvent, AuthModeInfo } from "../ipc/types";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
/** 官方授权方式中文名 */
|
||||
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 modeButtonLabel(mode: string): string {
|
||||
switch (mode) {
|
||||
case "browser_oauth":
|
||||
return "开始授权";
|
||||
case "device_code":
|
||||
return "开始授权";
|
||||
case "local_tui":
|
||||
return "打开终端授权";
|
||||
case "api_key":
|
||||
return "用已保存的 Key 授权";
|
||||
default:
|
||||
return "去授权";
|
||||
}
|
||||
}
|
||||
|
||||
interface FlowState {
|
||||
mode: string;
|
||||
events: AuthFlowEvent[];
|
||||
device: { code: string; url: string } | null;
|
||||
done: boolean | null; // null=进行中
|
||||
}
|
||||
|
||||
/** 软件内授权面板(Wave 2.2 Req 2):把「说明文字」升级为可操作授权流程。
|
||||
* 覆盖 API Key / 浏览器 / 设备码 / 本机终端四类,事件流经 cli-auth-event 实时回传。 */
|
||||
export function AuthPanel({
|
||||
id,
|
||||
authModes,
|
||||
onAuthChanged,
|
||||
}: {
|
||||
id: string;
|
||||
authModes: AuthModeInfo[];
|
||||
onAuthChanged: () => void;
|
||||
}) {
|
||||
const [flow, setFlow] = useState<FlowState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
onCliAuth((ev) => {
|
||||
if (ev.cli_id !== id) return;
|
||||
setFlow((f) => {
|
||||
if (!f) return f;
|
||||
const next: FlowState = { ...f, events: [...f.events, ev] };
|
||||
if (ev.kind === "device_code" && ev.user_code && ev.verification_url) {
|
||||
next.device = { code: ev.user_code, url: ev.verification_url };
|
||||
}
|
||||
if (ev.kind === "done") {
|
||||
next.done = ev.authorized ?? false;
|
||||
onAuthChanged();
|
||||
}
|
||||
if (ev.kind === "error" || ev.kind === "cancelled") {
|
||||
next.done = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
return () => unlisten?.();
|
||||
}, [id, onAuthChanged]);
|
||||
|
||||
function start(mode: string) {
|
||||
setFlow({ mode, events: [], device: null, done: null });
|
||||
void authorize(id, mode);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (flow) void cancelAuthorize(id, flow.mode);
|
||||
}
|
||||
|
||||
function close() {
|
||||
setFlow(null);
|
||||
}
|
||||
|
||||
if (authModes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="auth-modes">
|
||||
{authModes.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>}
|
||||
<button type="button" className="btn btn-secondary auth-mode-action" onClick={() => start(m.mode)}>
|
||||
{modeButtonLabel(m.mode)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{flow && (
|
||||
<Modal
|
||||
title={`${authModeLabel(flow.mode)}授权 · ${id}`}
|
||||
onClose={flow.done == null ? undefined : close}
|
||||
footer={
|
||||
flow.done == null ? (
|
||||
<button type="button" className="btn btn-secondary" onClick={cancel}>
|
||||
<X size={14} strokeWidth={1.5} aria-hidden="true" /> 取消授权
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={close}>
|
||||
完成
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<AuthFlowBody flow={flow} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthFlowBody({ flow }: { flow: FlowState }) {
|
||||
const { device, events, done } = flow;
|
||||
return (
|
||||
<div className="auth-flow">
|
||||
{/* 设备码:大号验证码 + 一键复制 + 打开授权网页 */}
|
||||
{device && (
|
||||
<div className="auth-device">
|
||||
<div className="auth-device-label">在浏览器打开验证链接,输入以下设备码</div>
|
||||
<div className="auth-device-code">
|
||||
<span className="auth-device-code-text">{device.code}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary auth-copy"
|
||||
onClick={() => void navigator.clipboard.writeText(device.code)}
|
||||
>
|
||||
<Copy size={14} strokeWidth={1.5} aria-hidden="true" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<a className="btn btn-primary auth-open" href={device.url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={14} strokeWidth={1.5} aria-hidden="true" /> 打开授权网页
|
||||
</a>
|
||||
<div className="auth-device-hint">请在弹出的浏览器里完成确认,本软件会轮询授权结果。</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态行 */}
|
||||
<div className="auth-status">
|
||||
{done == null ? (
|
||||
<span className="auth-waiting">
|
||||
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" />
|
||||
{device ? "等待浏览器确认…(可随时取消)" : "授权进行中,等待浏览器确认…(可随时取消)"}
|
||||
</span>
|
||||
) : done ? (
|
||||
<span className="auth-done">
|
||||
<ShieldCheck size={14} strokeWidth={1.5} aria-hidden="true" /> 已授权 ✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="auth-failed">
|
||||
<X size={14} strokeWidth={1.5} aria-hidden="true" /> 授权未完成,请重试或改用其它方式
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 实时输出(脱敏后) */}
|
||||
{events.length > 0 && (
|
||||
<div className="auth-log">
|
||||
{events
|
||||
.filter((e) => e.kind === "line" || e.kind === "waiting")
|
||||
.map((e, i) => (
|
||||
<div key={i} className={`auth-log-line ${e.kind}`}>
|
||||
{e.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,27 +7,13 @@ import type {
|
||||
ConfigVerifyResult,
|
||||
WriteResult,
|
||||
} from "../ipc/types";
|
||||
import { AuthPanel } from "./AuthPanel";
|
||||
import { MonoChip } from "./MonoChip";
|
||||
|
||||
/** 官方授权方式中文名 */
|
||||
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 }) {
|
||||
* Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」
|
||||
* Wave 2.2:授权/登录层改为可操作授权流程(AuthPanel) */
|
||||
export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?: () => void }) {
|
||||
const [state, setState] = useState<ConfigFormState | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
||||
@@ -138,24 +124,12 @@ export function ConfigForm({ id }: { id: string }) {
|
||||
<div className="config-form">
|
||||
{!hasFields && <p className="panel-empty">该 CLI 未声明可配置的中文表单字段。</p>}
|
||||
|
||||
{/* 第一层:授权 / 登录(官方授权方式引导 + API Key 类字段) */}
|
||||
{/* 第一层:授权 / 登录(可操作授权流程 + 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>
|
||||
<AuthPanel id={id} authModes={state.auth_modes} onAuthChanged={onAuthChanged ?? (() => {})} />
|
||||
)}
|
||||
{authFields.map(renderField)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Download, ExternalLink, Loader2, ShieldAlert } from "lucide-react";
|
||||
import { installRuntime, onRuntimeInstall, openRuntimePage, previewRuntimeInstall } from "../ipc";
|
||||
import type { RuntimeInstallEvent, RuntimeSource } from "../ipc/types";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
const RUNTIME_LABELS: Record<string, string> = {
|
||||
node: "Node.js",
|
||||
python: "Python",
|
||||
git: "Git",
|
||||
winget: "winget",
|
||||
uv: "uv",
|
||||
};
|
||||
|
||||
/** 本机环境运行时一键安装弹窗(Wave 2.2 Req 3):
|
||||
* 展示官方来源/体积/权限 → 确认后下载官方安装包并打开安装向导 → 失败兜底打开官网下载页。 */
|
||||
export function RuntimeInstallModal({ runtime, onClose }: { runtime: string; onClose: () => void }) {
|
||||
const [source, setSource] = useState<RuntimeSource | null>(null);
|
||||
const [phase, setPhase] = useState<"preview" | "running" | "done" | "error" | "opened">("preview");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
previewRuntimeInstall(runtime)
|
||||
.then((s) => {
|
||||
if (!cancelled) setSource(s);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [runtime]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
onRuntimeInstall((ev: RuntimeInstallEvent) => {
|
||||
if (ev.runtime !== runtime) return;
|
||||
setMessage(ev.message);
|
||||
if (ev.phase === "done") setPhase("done");
|
||||
else if (ev.phase === "opened_page") setPhase("opened");
|
||||
else if (ev.phase === "error") {
|
||||
setPhase("error");
|
||||
setError(ev.message);
|
||||
} else {
|
||||
setPhase("running");
|
||||
}
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
return () => unlisten?.();
|
||||
}, [runtime]);
|
||||
|
||||
function start() {
|
||||
setPhase("running");
|
||||
setMessage("");
|
||||
setError(null);
|
||||
void installRuntime(runtime);
|
||||
}
|
||||
|
||||
function openPage() {
|
||||
void openRuntimePage(runtime);
|
||||
setPhase("opened");
|
||||
}
|
||||
|
||||
const label = RUNTIME_LABELS[runtime] ?? runtime;
|
||||
|
||||
return (
|
||||
<Modal title={`安装 ${label}`} onClose={onClose} footer={<Footer phase={phase} source={source} onStart={start} onOpenPage={openPage} onClose={onClose} />}>
|
||||
{error && phase === "preview" && !source ? (
|
||||
<p className="panel-empty">无法获取安装来源:{error}</p>
|
||||
) : !source ? (
|
||||
<p className="panel-empty">加载安装来源…</p>
|
||||
) : (
|
||||
<div className="runtime-install">
|
||||
<div className="runtime-source">
|
||||
<div className="confirm-label">官方来源</div>
|
||||
<p className="confirm-text">{source.source_label}</p>
|
||||
{source.download_url && (
|
||||
<p className="runtime-url">
|
||||
<span className="confirm-label">下载地址 </span>
|
||||
<span className="runtime-url-text">{source.download_url}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="runtime-meta">
|
||||
<div>
|
||||
<span className="confirm-label">大概体积 </span>
|
||||
<span className="confirm-text">{source.size_approx}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="confirm-label">需要的权限 </span>
|
||||
<span className="confirm-text">{source.elevate_needed ? "需要管理员权限(安装向导会弹 UAC 确认)" : "无需管理员权限"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(phase === "running" || phase === "done") && (
|
||||
<div className="runtime-progress">
|
||||
{phase === "running" && (
|
||||
<span className="auth-waiting">
|
||||
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" />
|
||||
{message || "处理中…"}
|
||||
</span>
|
||||
)}
|
||||
{phase === "done" && (
|
||||
<span className="auth-done">
|
||||
<Check size={14} strokeWidth={1.5} aria-hidden="true" /> {message || "安装向导已打开"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "error" && error && (
|
||||
<div className="runtime-error">
|
||||
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="runtime-note">
|
||||
仅从官方渠道下载,不静默安装;应用本身不提权。下载完成后会打开安装向导,你在向导里点完即装。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({
|
||||
phase,
|
||||
source,
|
||||
onStart,
|
||||
onOpenPage,
|
||||
onClose,
|
||||
}: {
|
||||
phase: "preview" | "running" | "done" | "error" | "opened";
|
||||
source: RuntimeSource | null;
|
||||
onStart: () => void;
|
||||
onOpenPage: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const running = phase === "running";
|
||||
const finished = phase === "done" || phase === "opened";
|
||||
const failed = phase === "error";
|
||||
const canDirect = source?.direct_installable && source?.download_url;
|
||||
return (
|
||||
<>
|
||||
{(failed || !canDirect) && (
|
||||
<button type="button" className="btn btn-secondary" onClick={onOpenPage}>
|
||||
<ExternalLink size={14} strokeWidth={1.5} aria-hidden="true" /> 打开官方下载页
|
||||
</button>
|
||||
)}
|
||||
{finished ? (
|
||||
<button type="button" className="btn btn-primary" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={onStart} disabled={running || !canDirect}>
|
||||
<Download size={14} strokeWidth={1.5} aria-hidden="true" /> {running ? "下载中…" : "下载并打开安装向导"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,14 @@ export interface UseDetectAllResult {
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// 检测结果本地缓存(Wave 2.2 Req 4):切换页面回来先用缓存立即渲染,
|
||||
// 后台静默刷新有变化再更新,不再每次重检重闪。
|
||||
let cachedMap: Record<string, DetectResult> | null = null;
|
||||
|
||||
/** 批量检测全部 CLI(总览/目录/我的 CLI 接真机状态) */
|
||||
export function useDetectAll(): UseDetectAllResult {
|
||||
const [detectMap, setDetectMap] = useState<Record<string, DetectResult>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detectMap, setDetectMap] = useState<Record<string, DetectResult>>(cachedMap ?? {});
|
||||
const [loading, setLoading] = useState(cachedMap == null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -20,6 +24,7 @@ export function useDetectAll(): UseDetectAllResult {
|
||||
const list = await detectCliAll();
|
||||
const map: Record<string, DetectResult> = {};
|
||||
for (const d of list) map[d.cli_id] = d;
|
||||
cachedMap = map;
|
||||
setDetectMap(map);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,10 +8,13 @@ export interface UseEnvResult {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 加载本机环境检测结果(一次性的只读快照) */
|
||||
// 本机环境本地缓存(Wave 2.2 Req 4):切换页面先用缓存立即渲染,后台静默刷新。
|
||||
let cachedEnv: PlatformEnv | null = null;
|
||||
|
||||
/** 加载本机环境检测结果(缓存优先 + 后台刷新) */
|
||||
export function useEnv(): UseEnvResult {
|
||||
const [env, setEnv] = useState<PlatformEnv | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [env, setEnv] = useState<PlatformEnv | null>(cachedEnv);
|
||||
const [loading, setLoading] = useState(cachedEnv == null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,6 +22,7 @@ export function useEnv(): UseEnvResult {
|
||||
detectEnv()
|
||||
.then((e) => {
|
||||
if (!cancelled) {
|
||||
cachedEnv = e;
|
||||
setEnv(e);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
Adapter,
|
||||
AuthFlowEvent,
|
||||
AuthStatus,
|
||||
CatalogEntry,
|
||||
CliAction,
|
||||
@@ -13,6 +14,8 @@ import type {
|
||||
DiagnosticReport,
|
||||
DryRunPlan,
|
||||
PlatformEnv,
|
||||
RuntimeInstallEvent,
|
||||
RuntimeSource,
|
||||
WriteResult,
|
||||
} from "./types";
|
||||
|
||||
@@ -98,6 +101,53 @@ export async function diagnose(id: string): Promise<DiagnosticReport> {
|
||||
return invoke<DiagnosticReport>("diagnose", { id });
|
||||
}
|
||||
|
||||
export async function diagnoseAll(): Promise<DiagnosticReport[]> {
|
||||
if (!isTauri()) return [];
|
||||
return invoke<DiagnosticReport[]>("diagnoseAll");
|
||||
}
|
||||
|
||||
/** 软件内授权:启动指定模式的授权流程(事件经 cli-auth-event 流式回传)。 */
|
||||
export async function authorize(id: string, mode: string): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
return invoke<void>("authorize", { id, mode });
|
||||
}
|
||||
|
||||
export async function cancelAuthorize(id: string, mode: string): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
return invoke<void>("cancelAuthorize", { id, mode });
|
||||
}
|
||||
|
||||
/** 订阅授权流程事件(cli-auth-event)。 */
|
||||
export async function onCliAuth(
|
||||
handler: (payload: AuthFlowEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
if (!isTauri()) return () => {};
|
||||
return listen<AuthFlowEvent>("cli-auth-event", (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
/** 本机环境运行时安装(预览/一键安装/官网兜底)。 */
|
||||
export async function previewRuntimeInstall(runtime: string): Promise<RuntimeSource> {
|
||||
if (!isTauri()) return mockRuntimeSource(runtime);
|
||||
return invoke<RuntimeSource>("previewRuntimeInstall", { runtime });
|
||||
}
|
||||
|
||||
export async function installRuntime(runtime: string): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
return invoke<void>("installRuntime", { runtime });
|
||||
}
|
||||
|
||||
export async function openRuntimePage(runtime: string): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
return invoke<void>("openRuntimePage", { runtime });
|
||||
}
|
||||
|
||||
export async function onRuntimeInstall(
|
||||
handler: (payload: RuntimeInstallEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
if (!isTauri()) return () => {};
|
||||
return listen<RuntimeInstallEvent>("runtime-install-event", (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
/** 订阅 runAction 的流式事件(cli-action-event)。返回取消订阅函数。 */
|
||||
export async function onCliAction(
|
||||
handler: (payload: CliActionEvent) => void,
|
||||
@@ -256,3 +306,15 @@ const mockDryRun = (_id: string, action: CliAction): DryRunPlan => ({
|
||||
affected_files: ["~/.codex/config.toml"],
|
||||
rollback_zh: action === "install" ? "可执行卸载命令回退;配置文件默认保留。" : "卸载默认保留配置,可重新安装恢复。",
|
||||
});
|
||||
|
||||
const mockRuntimeSource = (runtime: string): RuntimeSource => ({
|
||||
runtime,
|
||||
label_zh: runtime === "node" ? "Node.js" : runtime === "python" ? "Python" : "Git",
|
||||
download_url: "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi",
|
||||
download_page: "https://nodejs.org/en/download",
|
||||
size_approx: "约 31 MB",
|
||||
elevate_needed: true,
|
||||
allowed_hosts: ["nodejs.org"],
|
||||
direct_installable: true,
|
||||
source_label: "官方来源",
|
||||
});
|
||||
|
||||
@@ -186,6 +186,54 @@ export interface DiagnosticReport {
|
||||
findings: DiagnosticFinding[];
|
||||
}
|
||||
|
||||
// ---- Wave 2.2:错误人话化 / 授权流程 / 运行时安装 ----
|
||||
|
||||
export interface ErrorHint {
|
||||
code: string;
|
||||
friendly_zh: string;
|
||||
raw: string;
|
||||
missing_runtime: string | null;
|
||||
}
|
||||
|
||||
export type AuthFlowKind = "started" | "line" | "device_code" | "waiting" | "done" | "error" | "cancelled";
|
||||
|
||||
export interface AuthFlowEvent {
|
||||
cli_id: string;
|
||||
mode: string;
|
||||
kind: AuthFlowKind;
|
||||
message: string;
|
||||
user_code: string | null;
|
||||
verification_url: string | null;
|
||||
authorized: boolean | null;
|
||||
}
|
||||
|
||||
export interface RuntimeSource {
|
||||
runtime: string;
|
||||
label_zh: string;
|
||||
download_url: string | null;
|
||||
download_page: string;
|
||||
size_approx: string;
|
||||
elevate_needed: boolean;
|
||||
allowed_hosts: string[];
|
||||
direct_installable: boolean;
|
||||
source_label: string;
|
||||
}
|
||||
|
||||
export type RuntimeInstallPhase =
|
||||
| "validate"
|
||||
| "download"
|
||||
| "open"
|
||||
| "done"
|
||||
| "error"
|
||||
| "opened_page";
|
||||
|
||||
export interface RuntimeInstallEvent {
|
||||
runtime: string;
|
||||
phase: RuntimeInstallPhase;
|
||||
message: string;
|
||||
fallback?: boolean;
|
||||
}
|
||||
|
||||
// ---- 适配器完整定义(getAdapter 返回,detail 页展示用) ----
|
||||
|
||||
export interface AdapterChannel {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
DetectResult,
|
||||
DiagnosticReport,
|
||||
DryRunPlan,
|
||||
ErrorHint,
|
||||
} from "../ipc/types";
|
||||
import { CliMonogram } from "../components/CliMonogram";
|
||||
import { MonoChip } from "../components/MonoChip";
|
||||
@@ -40,7 +41,7 @@ type RunPhase = "prepare" | "exec" | "verify";
|
||||
|
||||
type RunOutcome =
|
||||
| { status: "success"; action: CliAction; detect: DetectResult | null }
|
||||
| { status: "failed"; action: CliAction; message: string };
|
||||
| { status: "failed"; action: CliAction; message: string; hint: ErrorHint | null };
|
||||
|
||||
/** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */
|
||||
function AuthLight({ auth }: { auth: AuthStatus | null }) {
|
||||
@@ -90,7 +91,15 @@ function detectLabel(d: DetectResult | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }) {
|
||||
export function CliDetailPage({
|
||||
id,
|
||||
onBack,
|
||||
onInstallRuntime,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onInstallRuntime?: (runtime: string) => 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);
|
||||
@@ -119,7 +128,8 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
|
||||
}
|
||||
if (ev.kind === "error") {
|
||||
setRunning(false);
|
||||
setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message });
|
||||
const hint = (ev.data as ErrorHint | null) ?? null;
|
||||
setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message, hint });
|
||||
return;
|
||||
}
|
||||
if (ev.phase) setPhase(ev.phase as RunPhase);
|
||||
@@ -239,7 +249,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
|
||||
|
||||
<section className="cli-detail-body">
|
||||
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} auth={auth} />}
|
||||
{tab === "config" && <ConfigForm id={id} />}
|
||||
{tab === "config" && <ConfigForm id={id} onAuthChanged={refresh} />}
|
||||
{tab === "docs" && <DocsTab adapter={adapter} />}
|
||||
{tab === "diag" && <DiagTab id={id} />}
|
||||
</section>
|
||||
@@ -277,7 +287,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
|
||||
)
|
||||
}
|
||||
>
|
||||
<RunBody phase={phase} log={log} outcome={outcome} />
|
||||
<RunBody phase={phase} log={log} outcome={outcome} onInstallRuntime={onInstallRuntime} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
@@ -309,10 +319,12 @@ function RunBody({
|
||||
phase,
|
||||
log,
|
||||
outcome,
|
||||
onInstallRuntime,
|
||||
}: {
|
||||
phase: RunPhase;
|
||||
log: ActionEvent[];
|
||||
outcome: RunOutcome | null;
|
||||
onInstallRuntime?: (runtime: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="run-body">
|
||||
@@ -349,15 +361,70 @@ function RunBody({
|
||||
</div>
|
||||
)}
|
||||
{outcome?.status === "failed" && (
|
||||
<div className="run-result failed">
|
||||
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>{outcome.message}</span>
|
||||
</div>
|
||||
<FailureResult outcome={outcome} onInstallRuntime={onInstallRuntime} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 失败态:默认展示人话版建议;原始报错折叠保留;缺失运行时给内联「去安装」按钮(联动本机环境一键装) */
|
||||
function FailureResult({
|
||||
outcome,
|
||||
onInstallRuntime,
|
||||
}: {
|
||||
outcome: { status: "failed"; action: CliAction; message: string; hint: ErrorHint | null };
|
||||
onInstallRuntime?: (runtime: string) => void;
|
||||
}) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const hint = outcome.hint;
|
||||
return (
|
||||
<div className="run-result failed run-failure">
|
||||
<div className="run-failure-head">
|
||||
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>{hint?.friendly_zh ?? outcome.message}</span>
|
||||
</div>
|
||||
{hint?.missing_runtime && onInstallRuntime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary run-failure-action"
|
||||
onClick={() => onInstallRuntime(hint.missing_runtime!)}
|
||||
>
|
||||
去安装 {runtimeLabel(hint.missing_runtime)}
|
||||
</button>
|
||||
)}
|
||||
{hint?.raw && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn run-failure-toggle"
|
||||
onClick={() => setShowRaw((v) => !v)}
|
||||
>
|
||||
原始报错 {showRaw ? "收起" : "展开"}
|
||||
</button>
|
||||
{showRaw && <pre className="run-failure-raw">{hint.raw}</pre>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function runtimeLabel(runtime: string): string {
|
||||
switch (runtime) {
|
||||
case "node":
|
||||
return "Node.js";
|
||||
case "python":
|
||||
return "Python";
|
||||
case "git":
|
||||
return "Git";
|
||||
case "winget":
|
||||
return "winget";
|
||||
case "uv":
|
||||
return "uv";
|
||||
default:
|
||||
return runtime;
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动滚动到底部(把 ref 挂在日志末尾) */
|
||||
function useAutoScroll(dep: unknown) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
+271
-124
@@ -1,13 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity, Archive, Download, Loader2, Lock, Plus, ScanSearch } from "lucide-react";
|
||||
import { useEnv } from "../hooks/useEnv";
|
||||
import { useCatalog } from "../hooks/useCatalog";
|
||||
import { useDetectAll } from "../hooks/useDetectAll";
|
||||
import { diagnoseAll } from "../ipc";
|
||||
import type {
|
||||
CatalogEntry,
|
||||
DetectResult,
|
||||
DiagnosticReport,
|
||||
PlatformEnv,
|
||||
RuntimeInfo,
|
||||
RuntimeStatus,
|
||||
} from "../ipc/types";
|
||||
import { KpiCard } from "../components/KpiCard";
|
||||
import { MonoChip } from "../components/MonoChip";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { CliMonogram } from "../components/CliMonogram";
|
||||
import type { CatalogEntry, DetectResult, PlatformEnv, RuntimeInfo, RuntimeStatus } from "../ipc/types";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { RuntimeInstallModal } from "../components/RuntimeInstallModal";
|
||||
import type { PageKey } from "../components/Sidebar";
|
||||
|
||||
/** 平台相关运行时集合(Windows 不看 apt,Linux 不看 winget) */
|
||||
@@ -30,34 +40,34 @@ function runtimeProblems(env: PlatformEnv): { name: string; info: RuntimeInfo }[
|
||||
function runtimeMeta(status: RuntimeStatus): {
|
||||
dot: string;
|
||||
missingText: string | null;
|
||||
needsGuide: boolean;
|
||||
needsInstall: boolean;
|
||||
} {
|
||||
switch (status) {
|
||||
case "installed":
|
||||
return { dot: "status-dot ok", missingText: null, needsGuide: false };
|
||||
return { dot: "status-dot ok", missingText: null, needsInstall: false };
|
||||
case "not_installed":
|
||||
return { dot: "status-dot unknown", missingText: "未检测到", needsGuide: true };
|
||||
return { dot: "status-dot unknown", missingText: "未检测到", needsInstall: true };
|
||||
case "not_in_path":
|
||||
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsGuide: false };
|
||||
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsInstall: false };
|
||||
case "permission_denied":
|
||||
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsGuide: false };
|
||||
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsInstall: false };
|
||||
case "exec_failed":
|
||||
return { dot: "status-dot warn breathe", missingText: "执行失败", needsGuide: false };
|
||||
return { dot: "status-dot warn breathe", missingText: "执行失败", needsInstall: false };
|
||||
case "version_unparseable":
|
||||
return { dot: "status-dot warn breathe", missingText: "版本未知", needsGuide: false };
|
||||
return { dot: "status-dot warn breathe", missingText: "版本未知", needsInstall: false };
|
||||
default:
|
||||
return { dot: "status-dot unknown", missingText: "未知", needsGuide: false };
|
||||
return { dot: "status-dot unknown", missingText: "未知", needsInstall: false };
|
||||
}
|
||||
}
|
||||
|
||||
function RuntimeItem({
|
||||
name,
|
||||
info,
|
||||
onNavigate,
|
||||
onInstall,
|
||||
}: {
|
||||
name: string;
|
||||
info: RuntimeInfo;
|
||||
onNavigate: (p: PageKey) => void;
|
||||
onInstall: (runtime: string) => void;
|
||||
}) {
|
||||
const meta = runtimeMeta(info.status);
|
||||
return (
|
||||
@@ -71,107 +81,121 @@ function RuntimeItem({
|
||||
) : (
|
||||
<span className="runtime-missing">{meta.missingText}</span>
|
||||
)}
|
||||
{meta.needsGuide && (
|
||||
<button type="button" className="link-btn runtime-action" onClick={() => onNavigate("catalog")}>
|
||||
安装指引
|
||||
{meta.needsInstall && (
|
||||
<button type="button" className="link-btn runtime-action" onClick={() => onInstall(name)}>
|
||||
<Download size={12} strokeWidth={1.5} aria-hidden="true" /> 安装
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvPanel({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
|
||||
const { env, loading, error } = useEnv();
|
||||
function EnvPanel({
|
||||
onInstallRuntime,
|
||||
}: {
|
||||
onInstallRuntime: (runtime: string) => void;
|
||||
}) {
|
||||
const { env, loading } = useEnv();
|
||||
const [showAllPath, setShowAllPath] = useState(false);
|
||||
|
||||
if (error) {
|
||||
if (!env && loading) {
|
||||
return (
|
||||
<section className="panel env-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">本机环境</h2>
|
||||
<span className="panel-hint">检测中…</span>
|
||||
</div>
|
||||
<div className="skeleton-rows">
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line short" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!env) {
|
||||
return (
|
||||
<section className="panel env-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">本机环境</h2>
|
||||
</div>
|
||||
<p className="panel-empty">环境检测失败:{error}</p>
|
||||
<p className="panel-empty">环境检测失败</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const pathPreview = env ? env.path_entries.slice(0, 8) : [];
|
||||
const pathPreview = env.path_entries.slice(0, 8);
|
||||
|
||||
return (
|
||||
<section className="panel env-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">本机环境</h2>
|
||||
{loading && <span className="panel-hint">检测中…</span>}
|
||||
{loading && <span className="panel-hint">刷新中…</span>}
|
||||
</div>
|
||||
{env && (
|
||||
<>
|
||||
<div className="env-grid">
|
||||
<div className="env-basic">
|
||||
<div className="env-row">
|
||||
<span className="env-key">系统</span>
|
||||
<span className="env-val">{env.os_version}</span>
|
||||
</div>
|
||||
<div className="env-row">
|
||||
<span className="env-key">架构</span>
|
||||
<span className="env-val">{env.arch}</span>
|
||||
</div>
|
||||
<div className="env-row">
|
||||
<span className="env-key">Shell</span>
|
||||
<span className="env-val">
|
||||
{env.shells.powershell_version ? (
|
||||
<>PowerShell <MonoChip>{env.shells.powershell_version}</MonoChip></>
|
||||
) : null}
|
||||
{env.shells.pwsh_version ? (
|
||||
<> · pwsh <MonoChip>{env.shells.pwsh_version}</MonoChip></>
|
||||
) : null}
|
||||
{env.shells.bash_available ? " · Bash ✓" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="env-row">
|
||||
<span className="env-key">密钥库</span>
|
||||
<span className="env-val">
|
||||
{env.capabilities.keyring === "ok" ? "Credential Manager ✓" : "密钥库缺失"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="env-row">
|
||||
<span className="env-key">提权</span>
|
||||
<span className="env-val">{env.capabilities.can_elevate ? "可用" : "不可用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-grid">
|
||||
{(["node", "npm", "python", "uv", "git", "winget", "apt"] as const).map(
|
||||
(key) => {
|
||||
const info = env.runtimes[key];
|
||||
if (!info) return null;
|
||||
return <RuntimeItem key={key} name={key} info={info} onNavigate={onNavigate} />;
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="env-grid">
|
||||
<div className="env-basic">
|
||||
<div className="env-row">
|
||||
<span className="env-key">系统</span>
|
||||
<span className="env-val">{env.os_version}</span>
|
||||
</div>
|
||||
<div className="path-block">
|
||||
<div className="path-head">
|
||||
<span className="path-title">
|
||||
PATH({env.path_entries.length} 条)
|
||||
<MonoChip>{env.path_entries.length}</MonoChip>
|
||||
</span>
|
||||
{env.path_entries.length > pathPreview.length && (
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => setShowAllPath((v) => !v)}
|
||||
>
|
||||
{showAllPath ? "收起" : "展开全部"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(showAllPath ? env.path_entries : pathPreview).map((entry, idx) => (
|
||||
<div key={idx} className="path-entry">
|
||||
{entry}
|
||||
</div>
|
||||
))}
|
||||
<div className="env-row">
|
||||
<span className="env-key">架构</span>
|
||||
<span className="env-val">{env.arch}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="env-row">
|
||||
<span className="env-key">Shell</span>
|
||||
<span className="env-val">
|
||||
{env.shells.powershell_version ? (
|
||||
<>PowerShell <MonoChip>{env.shells.powershell_version}</MonoChip></>
|
||||
) : null}
|
||||
{env.shells.pwsh_version ? (
|
||||
<> · pwsh <MonoChip>{env.shells.pwsh_version}</MonoChip></>
|
||||
) : null}
|
||||
{env.shells.bash_available ? " · Bash ✓" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="env-row">
|
||||
<span className="env-key">密钥库</span>
|
||||
<span className="env-val">
|
||||
{env.capabilities.keyring === "ok" ? "Credential Manager ✓" : "密钥库缺失"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="env-row">
|
||||
<span className="env-key">提权</span>
|
||||
<span className="env-val">{env.capabilities.can_elevate ? "可用" : "不可用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-grid">
|
||||
{(["node", "npm", "python", "uv", "git", "winget", "apt"] as const).map((key) => {
|
||||
const info = env.runtimes[key];
|
||||
if (!info) return null;
|
||||
return <RuntimeItem key={key} name={key} info={info} onInstall={onInstallRuntime} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="path-block">
|
||||
<div className="path-head">
|
||||
<span className="path-title">
|
||||
PATH({env.path_entries.length} 条)
|
||||
<MonoChip>{env.path_entries.length}</MonoChip>
|
||||
</span>
|
||||
{env.path_entries.length > pathPreview.length && (
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => setShowAllPath((v) => !v)}
|
||||
>
|
||||
{showAllPath ? "收起" : "展开全部"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(showAllPath ? env.path_entries : pathPreview).map((entry, idx) => (
|
||||
<div key={idx} className="path-entry">
|
||||
{entry}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -221,9 +245,29 @@ function Onboarding({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态,不渲染占位行与「查看全部」) */
|
||||
function DiagPanel() {
|
||||
// Wave 0.5 尚无诊断引擎数据,恒为空态(语义按 §3.7,不虚构记录)
|
||||
/** 检测进行中的骨架屏(Wave 2.2 Req 4:禁止在检测未完成时显示空态文案) */
|
||||
function OverviewSkeleton() {
|
||||
return (
|
||||
<div className="overview-skeleton" role="status" aria-label="正在检测本机 CLI">
|
||||
<div className="skeleton-notice">
|
||||
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
|
||||
正在检测本机 CLI…
|
||||
</div>
|
||||
<div className="skeleton-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card">
|
||||
<div className="skeleton-line avatar" />
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line short" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态;「立即诊断」跑全量诊断) */
|
||||
function DiagPanel({ onDiagnose }: { onDiagnose: () => void }) {
|
||||
const records: { cli: string; text: string; time: string }[] = [];
|
||||
return (
|
||||
<div className="panel diag-panel">
|
||||
@@ -233,7 +277,7 @@ function DiagPanel() {
|
||||
{records.length === 0 ? (
|
||||
<div className="diag-empty">
|
||||
<span className="diag-empty-text">暂无诊断记录</span>
|
||||
<button type="button" className="btn btn-secondary">
|
||||
<button type="button" className="btn btn-secondary" onClick={onDiagnose}>
|
||||
<ScanSearch size={16} strokeWidth={1.5} aria-hidden="true" /> 立即诊断
|
||||
</button>
|
||||
</div>
|
||||
@@ -257,9 +301,11 @@ function DiagPanel() {
|
||||
function QuickActions({
|
||||
firstRun,
|
||||
onNavigate,
|
||||
onDiagnose,
|
||||
}: {
|
||||
firstRun: boolean;
|
||||
onNavigate: (p: PageKey) => void;
|
||||
onDiagnose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel quick-card">
|
||||
@@ -267,7 +313,7 @@ function QuickActions({
|
||||
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
|
||||
<Plus size={16} strokeWidth={1.5} aria-hidden="true" /> 添加 CLI
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary">
|
||||
<button type="button" className="btn btn-secondary" onClick={onDiagnose}>
|
||||
<Activity size={16} strokeWidth={1.5} aria-hidden="true" /> 立即诊断
|
||||
</button>
|
||||
<button
|
||||
@@ -348,16 +394,47 @@ function CliBlock({
|
||||
interface OverviewProps {
|
||||
onNavigate: (p: PageKey) => void;
|
||||
onOpenDetail: (id: string) => void;
|
||||
pendingRuntime?: string | null;
|
||||
onRuntimeHandled?: () => void;
|
||||
}
|
||||
|
||||
/** 总览页(视觉规范 §3.1 + PRD §7):首跑空态 / KPI + 诊断/快速操作 + CLI 状态网格 + 本机环境 */
|
||||
export function OverviewPage({ onNavigate, onOpenDetail }: OverviewProps) {
|
||||
export function OverviewPage({ onNavigate, onOpenDetail, pendingRuntime, onRuntimeHandled }: OverviewProps) {
|
||||
const { env } = useEnv();
|
||||
const { entries } = useCatalog();
|
||||
const { detectMap } = useDetectAll();
|
||||
const { entries, loading: catalogLoading } = useCatalog();
|
||||
const { detectMap, loading: detecting } = useDetectAll();
|
||||
|
||||
const [runtimeInstall, setRuntimeInstall] = useState<string | null>(null);
|
||||
const [diagOpen, setDiagOpen] = useState(false);
|
||||
const [diagReports, setDiagReports] = useState<DiagnosticReport[] | null>(null);
|
||||
const [diagBusy, setDiagBusy] = useState(false);
|
||||
|
||||
// 外部跳转(详情页错误内联按钮「去安装 Node.js」)
|
||||
useEffect(() => {
|
||||
if (pendingRuntime) {
|
||||
setRuntimeInstall(pendingRuntime);
|
||||
onRuntimeHandled?.();
|
||||
}
|
||||
}, [pendingRuntime, onRuntimeHandled]);
|
||||
|
||||
async function runDiagnose() {
|
||||
setDiagOpen(true);
|
||||
setDiagBusy(true);
|
||||
setDiagReports(null);
|
||||
try {
|
||||
const reports = await diagnoseAll();
|
||||
setDiagReports(reports);
|
||||
} catch (e) {
|
||||
setDiagReports([{ cli_id: "", findings: [{ rule_id: "diagnose.failed", severity: "error", message_zh: String(e), evidence: null }] }]);
|
||||
} finally {
|
||||
setDiagBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const installedCount = entries.filter((e) => detectMap[e.id]?.status === "installed").length;
|
||||
const firstRun = installedCount === 0; // 无安装且无诊断记录(当前无诊断引擎)
|
||||
// 检测未完成(首次、无缓存)时不得判空态
|
||||
const ready = !detecting && !catalogLoading;
|
||||
const firstRun = ready && installedCount === 0;
|
||||
|
||||
const warningCount = env ? runtimeProblems(env).length : 0;
|
||||
const warningNames = env ? runtimeProblems(env).map((p) => p.name) : [];
|
||||
@@ -365,7 +442,7 @@ export function OverviewPage({ onNavigate, onOpenDetail }: OverviewProps) {
|
||||
return (
|
||||
<div className="overview">
|
||||
{/* ① KPI 一排 4 张(首跑空态隐藏,v1.3 §3.1.5) */}
|
||||
{!firstRun && (
|
||||
{ready && !firstRun && (
|
||||
<section className="kpi-grid" aria-label="概览指标">
|
||||
<KpiCard label="已安装" value={installedCount} tone="primary" subtitle="暂无安装" action={{ label: "去目录看看", onClick: () => onNavigate("catalog") }} />
|
||||
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权" />
|
||||
@@ -380,35 +457,105 @@ export function OverviewPage({ onNavigate, onOpenDetail }: OverviewProps) {
|
||||
)}
|
||||
|
||||
{/* ② 中段:诊断/引导 + 快速操作(超宽三栏见 global.css) */}
|
||||
<section className="overview-mid">
|
||||
{firstRun ? <Onboarding onNavigate={onNavigate} /> : <DiagPanel />}
|
||||
<QuickActions firstRun={firstRun} onNavigate={onNavigate} />
|
||||
</section>
|
||||
{ready ? (
|
||||
<section className="overview-mid">
|
||||
{firstRun ? <Onboarding onNavigate={onNavigate} /> : <DiagPanel onDiagnose={runDiagnose} />}
|
||||
<QuickActions firstRun={firstRun} onNavigate={onNavigate} onDiagnose={runDiagnose} />
|
||||
</section>
|
||||
) : (
|
||||
<section className="overview-mid">
|
||||
<OverviewSkeleton />
|
||||
<QuickActions firstRun onNavigate={onNavigate} onDiagnose={runDiagnose} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ③ 下段:CLI 状态网格通栏(首跑精简:可安装列表 + 安装入口) */}
|
||||
<section className="panel cli-status-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">
|
||||
{firstRun ? `可安装的 CLI(${entries.length})` : "CLI 状态"}
|
||||
</h2>
|
||||
{/* 「管理全部 N 个」入口:仅当未全量展示时才渲染(v1.3 §3.1.4),本页恒全量展示,故不渲染 */}
|
||||
</div>
|
||||
<div className="cli-status-grid">
|
||||
{entries.map((entry) => (
|
||||
<CliBlock
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
detect={detectMap[entry.id]}
|
||||
compact={firstRun}
|
||||
onNavigate={onNavigate}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{!ready ? (
|
||||
<OverviewSkeleton />
|
||||
) : (
|
||||
<section className="panel cli-status-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">
|
||||
{firstRun ? `可安装的 CLI(${entries.length})` : "CLI 状态"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="cli-status-grid">
|
||||
{entries.map((entry) => (
|
||||
<CliBlock
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
detect={detectMap[entry.id]}
|
||||
compact={firstRun}
|
||||
onNavigate={onNavigate}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏) */}
|
||||
<EnvPanel onNavigate={onNavigate} />
|
||||
<EnvPanel onInstallRuntime={setRuntimeInstall} />
|
||||
|
||||
{/* 运行时一键安装弹窗 */}
|
||||
{runtimeInstall && (
|
||||
<RuntimeInstallModal runtime={runtimeInstall} onClose={() => setRuntimeInstall(null)} />
|
||||
)}
|
||||
|
||||
{/* 全量诊断汇总弹窗 */}
|
||||
{diagOpen && (
|
||||
<Modal title="诊断结果" onClose={() => setDiagOpen(false)}>
|
||||
<DiagSummary busy={diagBusy} reports={diagReports} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagSummary({ busy, reports }: { busy: boolean; reports: DiagnosticReport[] | null }) {
|
||||
if (busy) {
|
||||
return (
|
||||
<p className="panel-empty">
|
||||
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" /> 正在对已装工具运行诊断…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!reports) return null;
|
||||
const allFindings = reports.flatMap((r) => r.findings);
|
||||
const errors = allFindings.filter((f) => f.severity === "error").length;
|
||||
const warns = allFindings.filter((f) => f.severity === "warn").length;
|
||||
return (
|
||||
<div className="diag-summary-all">
|
||||
<div className="diag-summary">
|
||||
{allFindings.length === 0 ? (
|
||||
<>
|
||||
<span className="status-dot ok" aria-hidden="true" />
|
||||
<span>已装工具全部正常,未发现问题</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={`status-dot ${errors > 0 ? "error" : "warn"}`} aria-hidden="true" />
|
||||
<span>
|
||||
共 {allFindings.length} 项:{errors} 个错误、{warns} 个警告、{allFindings.length - errors - warns} 个提示
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{reports.map((r) =>
|
||||
r.findings.length === 0 ? null : (
|
||||
<div key={r.cli_id} className="diag-report-group">
|
||||
<div className="diag-report-cli">{r.cli_id}</div>
|
||||
{r.findings.map((f, i) => (
|
||||
<div key={i} className="diag-finding-row">
|
||||
<span className={`diag-sev-label ${f.severity}`}>
|
||||
{f.severity === "error" ? "错误" : f.severity === "warn" ? "警告" : "提示"}
|
||||
</span>
|
||||
<span className="diag-finding-row-msg">{f.message_zh}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2205,3 +2205,319 @@ button {
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-5);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
* Wave 2.2£ºÊµÊ±Êä³ö / Èí¼þÄÚÊÚȨ / ÒÀÀµÒ»¼ü×° / ¼ÓÔØ¹Ç¼Ü
|
||||
* È«²¿ÑÕÉ«À´×Ô tokens/
|
||||
* ============================================================ */
|
||||
|
||||
/* ---------- ʧ°Ü̬ÈË»°»¯£¨°²×°/Ð¶ÔØµ¯´°£© ---------- */
|
||||
.run-failure {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.run-failure-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ad-space-2);
|
||||
}
|
||||
|
||||
.run-failure-action {
|
||||
margin-top: var(--ad-space-2);
|
||||
}
|
||||
|
||||
.run-failure-toggle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.run-failure-raw {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---------- Èí¼þÄÚÊÚȨ£¨AuthPanel£© ---------- */
|
||||
.auth-mode-action {
|
||||
height: 28px;
|
||||
min-width: 0;
|
||||
padding: 0 var(--ad-space-3);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-4);
|
||||
}
|
||||
|
||||
.auth-device {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ad-space-3);
|
||||
padding: var(--ad-space-5);
|
||||
border: 1px solid var(--ad-border);
|
||||
border-radius: var(--ad-radius-m);
|
||||
background: var(--ad-bg-1);
|
||||
}
|
||||
|
||||
.auth-device-label {
|
||||
color: var(--ad-text-2);
|
||||
font-size: var(--ad-text-s-size);
|
||||
}
|
||||
|
||||
.auth-device-code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ad-space-3);
|
||||
}
|
||||
|
||||
.auth-device-code-text {
|
||||
font-family: var(--ad-font-mono);
|
||||
font-size: var(--ad-text-num-size);
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ad-primary);
|
||||
}
|
||||
|
||||
.auth-copy {
|
||||
height: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-open {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-device-hint {
|
||||
color: var(--ad-text-3);
|
||||
font-size: var(--ad-text-xs-size);
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ad-space-2);
|
||||
font-size: var(--ad-text-s-size);
|
||||
}
|
||||
|
||||
.auth-waiting {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ad-space-2);
|
||||
color: var(--ad-attention);
|
||||
}
|
||||
|
||||
.auth-done {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ad-space-2);
|
||||
color: var(--ad-success);
|
||||
}
|
||||
|
||||
.auth-failed {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ad-space-2);
|
||||
color: var(--ad-danger);
|
||||
}
|
||||
|
||||
.auth-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: var(--ad-space-3);
|
||||
border: 1px solid var(--ad-border);
|
||||
border-radius: var(--ad-radius-s);
|
||||
background: var(--ad-bg-0);
|
||||
font-family: var(--ad-font-mono);
|
||||
font-size: var(--ad-text-xs-size);
|
||||
}
|
||||
|
||||
.auth-log-line {
|
||||
color: var(--ad-text-2);
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.auth-log-line.waiting {
|
||||
color: var(--ad-attention);
|
||||
}
|
||||
|
||||
/* ---------- ±¾»ú»·¾³Ò»¼ü°²×°£¨RuntimeInstallModal£© ---------- */
|
||||
.runtime-install {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-4);
|
||||
}
|
||||
|
||||
.runtime-source,
|
||||
.runtime-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-2);
|
||||
}
|
||||
|
||||
.runtime-url {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-1);
|
||||
}
|
||||
|
||||
.runtime-url-text {
|
||||
font-family: var(--ad-font-mono);
|
||||
font-size: var(--ad-text-xs-size);
|
||||
color: var(--ad-text-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.runtime-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.runtime-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ad-space-2);
|
||||
padding: var(--ad-space-3);
|
||||
border: 1px solid var(--ad-border);
|
||||
border-radius: var(--ad-radius-m);
|
||||
color: var(--ad-danger);
|
||||
font-size: var(--ad-text-s-size);
|
||||
}
|
||||
|
||||
.runtime-note {
|
||||
color: var(--ad-text-3);
|
||||
font-size: var(--ad-text-xs-size);
|
||||
}
|
||||
|
||||
/* ---------- ×ÜÀÀ¼ÓÔØ¹Ç¼ÜÆÁ£¨Wave 2.2 Req 4£© ---------- */
|
||||
.overview-skeleton {
|
||||
padding: var(--ad-space-5);
|
||||
background: var(--ad-bg-1);
|
||||
border: 1px solid var(--ad-border);
|
||||
border-radius: var(--ad-radius-l);
|
||||
}
|
||||
|
||||
.skeleton-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ad-space-2);
|
||||
margin-bottom: var(--ad-space-4);
|
||||
color: var(--ad-text-2);
|
||||
font-size: var(--ad-text-m-size);
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--ad-space-4);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-2);
|
||||
padding: var(--ad-space-4);
|
||||
border: 1px solid var(--ad-border);
|
||||
border-radius: var(--ad-radius-m);
|
||||
}
|
||||
|
||||
.skeleton-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-3);
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background: var(--ad-bg-3);
|
||||
animation: ad-skeleton 1.4s var(--ad-ease-inout) infinite;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton-line.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--ad-radius-m);
|
||||
}
|
||||
|
||||
@keyframes ad-skeleton {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- È«Á¿Õï¶Ï»ã×Ü ---------- */
|
||||
.diag-summary-all {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-3);
|
||||
}
|
||||
|
||||
.diag-report-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ad-space-2);
|
||||
padding: var(--ad-space-3);
|
||||
border: 1px solid var(--ad-border);
|
||||
border-radius: var(--ad-radius-m);
|
||||
}
|
||||
|
||||
.diag-report-cli {
|
||||
color: var(--ad-primary);
|
||||
font-family: var(--ad-font-mono);
|
||||
font-size: var(--ad-text-s-size);
|
||||
}
|
||||
|
||||
.diag-finding-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ad-space-2);
|
||||
}
|
||||
|
||||
.diag-finding-row-msg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--ad-text-1);
|
||||
font-size: var(--ad-text-s-size);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* È«Á¿Õï¶Ï»ã×ÜÀïµÄ¼¶±ðÎÄ×Ö±êÇ©£¨½öÎÄ×ÖÉ«£¬²»Óà diag-sev-tag µÄÌî³äÉ«£¬±ÜÃâÎÄ×Ö±»±³¾°Í̵ô£© */
|
||||
.diag-sev-label {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--ad-text-xs-size);
|
||||
}
|
||||
|
||||
.diag-sev-label.error {
|
||||
color: var(--ad-danger);
|
||||
}
|
||||
|
||||
.diag-sev-label.warn {
|
||||
color: var(--ad-warning);
|
||||
}
|
||||
|
||||
.diag-sev-label.info {
|
||||
color: var(--ad-attention);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user