Merge pull request #1174 from pikasTech/fix/v03-system-routes-1155

fix: 补齐 v0.3 Vue system routes parity
This commit is contained in:
Lyon
2026-06-14 05:17:51 +08:00
committed by GitHub
11 changed files with 851 additions and 42 deletions
+6
View File
@@ -1,4 +1,5 @@
import type { ApiResult } from "@/types"; import type { ApiResult } from "@/types";
import { recordApiTiming } from "@/utils/rum";
export interface ActivityRef { export interface ActivityRef {
lastActivityAt: number; lastActivityAt: number;
@@ -68,8 +69,10 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
signal: controller.signal signal: controller.signal
}); });
const payload = await response.json().catch(() => null) as T | null; const payload = await response.json().catch(() => null) as T | null;
recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" });
return { ok: response.ok, status: response.status, data: payload, error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) }; return { ok: response.ok, status: response.status, data: payload, error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) };
} catch (error) { } catch (error) {
recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" });
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) { if (inactivityRef.current) {
const detail = inactivityRef.current; const detail = inactivityRef.current;
@@ -84,11 +87,14 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
} }
export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<string>> { export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<string>> {
const startedAt = Date.now();
try { try {
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin" }); const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin" });
const text = await response.text(); const text = await response.text();
recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" });
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` }; return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
} catch (error) { } catch (error) {
recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: "network_error" });
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) }; return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
} }
} }
+3
View File
@@ -7,6 +7,7 @@ export { accessAPI } from "./access";
export { providerProfilesAPI } from "./providerProfiles"; export { providerProfilesAPI } from "./providerProfiles";
export { apiKeysAPI } from "./apiKeys"; export { apiKeysAPI } from "./apiKeys";
export { usageAPI } from "./usage"; export { usageAPI } from "./usage";
export { systemAPI, type SkillUploadFileInput } from "./system";
import { fetchJson } from "./client"; import { fetchJson } from "./client";
import { accessAPI } from "./access"; import { accessAPI } from "./access";
@@ -17,6 +18,7 @@ import { hwpodAPI } from "./hwpod";
import { providerProfilesAPI } from "./providerProfiles"; import { providerProfilesAPI } from "./providerProfiles";
import { usageAPI } from "./usage"; import { usageAPI } from "./usage";
import { workbenchAPI } from "./workbench"; import { workbenchAPI } from "./workbench";
import { systemAPI } from "./system";
import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types"; import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types";
export const api = { export const api = {
@@ -28,6 +30,7 @@ export const api = {
providerProfiles: providerProfilesAPI, providerProfiles: providerProfilesAPI,
apiKeys: apiKeysAPI, apiKeys: apiKeysAPI,
usage: usageAPI, usage: usageAPI,
system: systemAPI,
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }), healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }), health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }), restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
+24
View File
@@ -0,0 +1,24 @@
import { fetchJson } from "./client";
import type {
ApiResult,
GateDiagnosticsResponse,
SkillFileResponse,
SkillTreeResponse,
SkillUploadResponse,
SkillsResponse,
WebPerformanceSummaryResponse
} from "@/types";
export interface SkillUploadFileInput {
relativePath: string;
contentText: string;
}
export const systemAPI = {
gateDiagnostics: (): Promise<ApiResult<GateDiagnosticsResponse>> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }),
performanceSummary: (): Promise<ApiResult<WebPerformanceSummaryResponse>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }),
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
uploadSkill: (name: string, files: SkillUploadFileInput[]): Promise<ApiResult<SkillUploadResponse>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ name: name.trim() || undefined, files }), timeoutMs: 30000, timeoutName: "skill upload" }),
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
skillFile: (skillId: string, path = "SKILL.md"): Promise<ApiResult<SkillFileResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(path)}`, { timeoutMs: 12000, timeoutName: "skill file" })
};
@@ -13,7 +13,7 @@ const navSections = [
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench" }, { name: "Dashboard", label: "概览", path: "/dashboard" }] }, { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench" }, { name: "Dashboard", label: "概览", path: "/dashboard" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys" }, { name: "Usage", label: "用量", path: "/usage" }] }, { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys" }, { name: "Usage", label: "用量", path: "/usage" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access" }, { name: "Users", label: "用户", path: "/admin/users" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles" }] }, { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access" }, { name: "Users", label: "用户", path: "/admin/users" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles" }] },
{ title: "系统", items: [{ name: "Performance", label: "性能", path: "/performance" }, { name: "Skills", label: "Skills", path: "/skills" }, { name: "Settings", label: "设置", path: "/settings" }, { name: "Help", label: "帮助", path: "/help" }] } { title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate" }, { name: "Performance", label: "性能", path: "/performance" }, { name: "Skills", label: "Skills", path: "/skills" }, { name: "Settings", label: "设置", path: "/settings" }, { name: "Help", label: "帮助", path: "/help" }] }
]; ];
const shellless = computed(() => route.name === "Login" || route.name === "NotFound"); const shellless = computed(() => route.name === "Login" || route.name === "NotFound");
+2
View File
@@ -4,6 +4,7 @@ import App from "./App.vue";
import router from "./router"; import router from "./router";
import i18n, { initI18n } from "./i18n"; import i18n, { initI18n } from "./i18n";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { installWebRum } from "@/utils/rum";
import "./style.css"; import "./style.css";
import "./styles/workbench.css"; import "./styles/workbench.css";
@@ -23,6 +24,7 @@ async function bootstrap(): Promise<void> {
app.use(router); app.use(router);
app.use(i18n); app.use(i18n);
await router.isReady(); await router.isReady();
installWebRum();
app.mount("#app"); app.mount("#app");
} }
+202 -1
View File
@@ -1395,6 +1395,199 @@
text-align: left; text-align: left;
} }
.system-page .data-table td small,
.provider-admin-page .data-table td small {
display: block;
margin-top: 3px;
color: #64748b;
font-size: 12px;
}
.system-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.system-summary-grid .metric-card {
display: grid;
gap: 5px;
padding: 14px;
}
.system-summary-grid .metric-card span,
.rum-bar-row span,
.skill-tree-list small {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.system-summary-grid .metric-card strong {
min-width: 0;
overflow-wrap: anywhere;
color: #0f172a;
font-size: 20px;
line-height: 1.15;
}
.system-summary-grid .metric-card small {
min-width: 0;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
}
.system-filter-bar {
display: grid;
grid-template-columns: minmax(140px, 220px) minmax(240px, 1fr);
gap: 10px;
}
.system-filter-bar label {
display: grid;
gap: 5px;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.system-filter-bar select,
.system-filter-bar input {
min-height: 36px;
min-width: 0;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: white;
padding: 7px 10px;
}
.system-row-detail {
display: -webkit-box;
max-width: 520px;
overflow: hidden;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.system-drilldown {
display: grid;
gap: 8px;
}
.system-drilldown h3,
.system-drilldown p {
margin: 0;
}
.system-drilldown h3 {
font-size: 13px;
}
.system-drilldown ul,
.skill-tree-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.system-drilldown li,
.skill-tree-list li {
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 8px;
}
.rum-chart-panel {
display: grid;
gap: 10px;
padding: 14px;
}
.rum-bar-row {
display: grid;
grid-template-columns: minmax(100px, 160px) minmax(0, 1fr) 48px;
align-items: center;
gap: 10px;
}
.rum-bar-row div {
height: 10px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
}
.rum-bar-row i {
display: block;
height: 100%;
border-radius: inherit;
background: #0e7490;
}
.rum-bar-row strong {
text-align: right;
}
.system-split-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.skills-tree-panel,
.skills-preview-panel {
display: grid;
align-content: start;
gap: 12px;
padding: 14px;
}
.skill-tree-list {
max-height: 420px;
overflow: auto;
}
.skill-tree-list li {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.skill-tree-list button {
min-height: 26px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: white;
color: #334155;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.skill-tree-list button:disabled {
color: #94a3b8;
}
.skill-tree-list span {
min-width: 0;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.skills-preview-panel .mini-log {
max-height: 460px;
min-height: 240px;
overflow: auto;
white-space: pre-wrap;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.platform-shell { .platform-shell {
grid-template-columns: 76px minmax(0, 1fr); grid-template-columns: 76px minmax(0, 1fr);
@@ -1413,7 +1606,9 @@
.workbench-grid, .workbench-grid,
.overview-grid, .overview-grid,
.settings-grid, .settings-grid,
.usage-panels { .usage-panels,
.system-summary-grid,
.system-split-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1444,4 +1639,10 @@
.platform-topbar { .platform-topbar {
padding: 0 12px; padding: 0 12px;
} }
.system-filter-bar,
.rum-bar-row,
.skill-tree-list li {
grid-template-columns: 1fr;
}
} }
+77 -3
View File
@@ -237,9 +237,83 @@ export interface HwpodSpecsResponse { specs?: Array<Record<string, unknown>>; [k
export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array<Record<string, unknown>>; [key: string]: unknown } export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown } export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown } export interface ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown }
export interface SkillsResponse { skills?: Array<Record<string, unknown>>; [key: string]: unknown } export interface GateDiagnosticRow extends Record<string, unknown> {
export interface SkillTreeResponse { tree?: Array<Record<string, unknown>>; [key: string]: unknown } category?: string;
export interface SkillFileResponse { content?: string; [key: string]: unknown } check?: string;
status?: string;
statusKey?: string;
owner?: string;
detail?: string;
evidence?: string | string[];
updatedAt?: string;
next?: string;
}
export interface GateDiagnosticsResponse {
status?: string;
route?: string;
contractVersion?: string;
sourceKind?: string;
liveBackend?: boolean;
observedAt?: string;
rowCount?: number;
rows?: GateDiagnosticRow[];
safety?: Record<string, unknown>;
[key: string]: unknown;
}
export interface WebPerformanceRow extends Record<string, unknown> {
kind?: string;
metric?: string;
route?: string;
method?: string;
statusClass?: string;
outcome?: string;
count?: number;
average?: number;
p50?: number;
p95?: number;
unit?: "seconds" | "score" | string;
tone?: "ok" | "warn" | "blocked" | "source" | string;
problem?: string;
}
export interface WebPerformanceSummaryResponse {
status?: string;
source?: string;
observedAt?: string;
namespace?: string;
gitopsTarget?: string;
summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; [key: string]: unknown };
apiRoutes?: WebPerformanceRow[];
webVitals?: WebPerformanceRow[];
longTasks?: WebPerformanceRow[];
problems?: WebPerformanceRow[];
rows?: WebPerformanceRow[];
[key: string]: unknown;
}
export interface SkillRecord extends Record<string, unknown> {
id: string;
source?: "preinstalled" | "uploaded" | string;
name?: string;
description?: string;
version?: string | null;
commitId?: string | null;
rootDir?: string;
manifestPath?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
fileCount?: number;
sizeBytes?: number;
}
export interface SkillTreeEntry extends Record<string, unknown> { path: string; type?: "directory" | "file" | string; sizeBytes?: number | null }
export interface SkillFilePayload { path?: string; sizeBytes?: number; contentType?: string; content?: string }
export interface SkillsResponse { count?: number; skills?: SkillRecord[]; roots?: Record<string, string>; agentRunAssembly?: Record<string, unknown>; [key: string]: unknown }
export interface SkillUploadResponse { accepted?: boolean; skill?: SkillRecord; [key: string]: unknown }
export interface SkillTreeResponse { skill?: SkillRecord; tree?: SkillTreeEntry[]; count?: number; [key: string]: unknown }
export interface SkillFileResponse { skill?: SkillRecord; file?: SkillFilePayload; content?: string; [key: string]: unknown }
export interface AdminAccessSummaryResponse { summary?: Record<string, unknown>; [key: string]: unknown } export interface AdminAccessSummaryResponse { summary?: Record<string, unknown>; [key: string]: unknown }
export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown } export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown }
+124
View File
@@ -0,0 +1,124 @@
interface RumEvent {
kind: "navigation" | "web_vital" | "api" | "long_task";
metric: string;
route?: string;
valueMs?: number;
value?: number;
method?: string;
status?: number;
statusClass?: string;
outcome?: string;
}
interface ApiTimingInput {
route: string;
method?: string;
status?: number;
startedAt: number;
outcome: string;
}
const SCHEMA_VERSION = "hwlab-web-performance-v1";
const FLUSH_INTERVAL_MS = 4000;
const MAX_QUEUE = 80;
const queue: RumEvent[] = [];
let installed = false;
let flushTimer: number | null = null;
export function installWebRum(): void {
if (installed || typeof window === "undefined") return;
installed = true;
window.addEventListener("load", () => window.setTimeout(recordNavigationTiming, 0), { once: true });
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushRum(true);
});
installPerformanceObservers();
}
export function recordApiTiming(input: ApiTimingInput): void {
if (typeof window === "undefined") return;
const valueMs = Math.max(0, Date.now() - input.startedAt);
enqueue({
kind: "api",
metric: "api_request",
route: input.route,
method: (input.method || "GET").toUpperCase(),
status: input.status ?? 0,
statusClass: statusClass(input.status),
outcome: input.outcome,
valueMs
});
}
function installPerformanceObservers(): void {
const Observer = window.PerformanceObserver;
if (!Observer) return;
observe("largest-contentful-paint", (entry) => enqueue({ kind: "web_vital", metric: "lcp", route: pageRoute(), valueMs: entry.startTime }));
observe("first-input", (entry) => {
const firstInput = entry as PerformanceEventTiming;
enqueue({ kind: "web_vital", metric: "fid", route: pageRoute(), valueMs: Math.max(0, firstInput.processingStart - firstInput.startTime) });
});
observe("layout-shift", (entry) => {
const shift = entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean };
if (shift.hadRecentInput) return;
enqueue({ kind: "web_vital", metric: "cls", route: pageRoute(), value: Number(shift.value) || 0 });
});
observe("longtask", (entry) => enqueue({ kind: "long_task", metric: "long_task", route: pageRoute(), valueMs: entry.duration }));
}
function observe(type: string, onEntry: (entry: PerformanceEntry) => void): void {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) onEntry(entry);
});
observer.observe({ type, buffered: true });
} catch {
// Older Chromium builds may not support every Web Vital entry type.
}
}
function recordNavigationTiming(): void {
const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
if (!navigation) return;
const route = pageRoute();
const start = navigation.startTime || 0;
enqueue({ kind: "navigation", metric: "navigation_ttfb", route, valueMs: Math.max(0, navigation.responseStart - start) });
enqueue({ kind: "navigation", metric: "navigation_dom_content_loaded", route, valueMs: Math.max(0, navigation.domContentLoadedEventEnd - start) });
enqueue({ kind: "navigation", metric: "navigation_load", route, valueMs: Math.max(0, navigation.loadEventEnd - start) });
}
function enqueue(event: RumEvent): void {
if (!Number.isFinite(event.valueMs ?? event.value ?? NaN)) return;
queue.push(event);
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
scheduleFlush();
}
function scheduleFlush(): void {
if (flushTimer !== null) return;
flushTimer = window.setTimeout(() => flushRum(false), FLUSH_INTERVAL_MS);
}
function flushRum(useBeacon: boolean): void {
if (flushTimer !== null) {
window.clearTimeout(flushTimer);
flushTimer = null;
}
if (!queue.length) return;
const events = queue.splice(0, queue.length);
const body = JSON.stringify({ schemaVersion: SCHEMA_VERSION, serviceId: "hwlab-cloud-web", page: pageRoute(), events });
if (useBeacon && navigator.sendBeacon) {
navigator.sendBeacon("/v1/web-performance", new Blob([body], { type: "application/json" }));
return;
}
void fetch("/v1/web-performance", { method: "POST", headers: { "content-type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => undefined);
}
function pageRoute(): string {
return `${window.location.pathname}${window.location.hash || ""}` || "/";
}
function statusClass(status?: number): string {
if (!status || !Number.isFinite(status)) return "network";
return `${Math.floor(status / 100)}xx`;
}
+127 -3
View File
@@ -1,11 +1,135 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { systemAPI } from "@/api";
import BaseDialog from "@/components/common/BaseDialog.vue";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
import EmptyState from "@/components/common/EmptyState.vue"; import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue"; import PageHeader from "@/components/common/PageHeader.vue";
import Pagination from "@/components/common/Pagination.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useTableLoader } from "@/composables/useTableLoader";
import type { GateDiagnosticRow, GateDiagnosticsResponse } from "@/types";
const columns: DataTableColumn[] = [
{ key: "category", label: "类别" },
{ key: "check", label: "检查项" },
{ key: "status", label: "状态" },
{ key: "owner", label: "归因对象" },
{ key: "detail", label: "关键细节" },
{ key: "actions", label: "下钻", align: "right" }
];
const payload = ref<GateDiagnosticsResponse | null>(null);
const query = ref("");
const statusFilter = ref("all");
const selectedRow = ref<GateDiagnosticRow | null>(null);
const page = ref(1);
const pageSize = 8;
const table = useTableLoader<GateDiagnosticRow>(async () => {
const response = await systemAPI.gateDiagnostics();
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
payload.value = response.data;
return response.data?.rows ?? [];
});
const rows = computed(() => table.rows.value);
const statusCounts = computed(() => ({
failed: rows.value.filter((row) => row.statusKey === "failed").length,
blocked: rows.value.filter((row) => row.statusKey === "blocked").length,
pending: rows.value.filter((row) => row.statusKey === "pending").length,
pass: rows.value.filter((row) => row.statusKey === "pass").length
}));
const filteredRows = computed(() => {
const needle = query.value.trim().toLowerCase();
return rows.value.filter((row) => {
if (statusFilter.value !== "all" && row.statusKey !== statusFilter.value) return false;
if (!needle) return true;
return [row.category, row.check, row.status, row.owner, row.detail, row.next, evidenceList(row).join(" ")]
.join(" ")
.toLowerCase()
.includes(needle);
});
});
const pageRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize));
watch([query, statusFilter], () => { page.value = 1; });
onMounted(() => void table.reload());
function rowKey(row: GateDiagnosticRow): string {
return `${row.category ?? "category"}:${row.check ?? "check"}`;
}
function evidenceList(row: GateDiagnosticRow): string[] {
if (Array.isArray(row.evidence)) return row.evidence.map(String);
if (typeof row.evidence === "string" && row.evidence.trim()) return [row.evidence];
return [];
}
function statusLabel(row: GateDiagnosticRow): string {
return row.status ?? row.statusKey ?? "unknown";
}
function displayDate(value?: string): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
</script> </script>
<template> <template>
<section class="route-stack"> <section class="route-stack system-page gate-page">
<PageHeader eyebrow="System" title="内部复核" description="Gate 不是默认首屏;只作为内部复核 route 保留。" /> <PageHeader eyebrow="System" title="内部复核" description="Gate diagnostics 直接读取 /v1/diagnostics/gate 的 live 聚合结果,用表格、筛选和 drill-down 展示失败归因。" />
<EmptyState title="Gate route 已迁移到 Vue" description="后续接 /v1/diagnostics/gate。" /> <LoadingState v-if="table.loading.value && !rows.length" />
<section v-else-if="table.error.value" class="data-panel">
<EmptyState title="Gate diagnostics 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
</section>
<template v-else>
<section class="system-summary-grid" aria-label="Gate live aggregation">
<article class="metric-card"><span>Status</span><strong>{{ payload?.status || 'unknown' }}</strong><small>{{ payload?.sourceKind || '-' }}</small></article>
<article class="metric-card"><span>Rows</span><strong>{{ rows.length }}</strong><small>{{ payload?.contractVersion || '-' }}</small></article>
<article class="metric-card"><span>Blocked</span><strong>{{ statusCounts.blocked + statusCounts.failed }}</strong><small>failed {{ statusCounts.failed }} / blocked {{ statusCounts.blocked }}</small></article>
<article class="metric-card"><span>Observed</span><strong>{{ displayDate(payload?.observedAt) }}</strong><small>liveBackend={{ payload?.liveBackend ? 'true' : 'false' }}</small></article>
</section>
<TablePageLayout title="Diagnostics" subtitle="live aggregation / filter / failure drill-down">
<template #actions>
<button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
</template>
<template #filters>
<div class="system-filter-bar">
<label><span>状态</span><select v-model="statusFilter"><option value="all">全部</option><option value="failed">failed</option><option value="blocked">blocked</option><option value="pending">pending</option><option value="pass">pass</option></select></label>
<label><span>搜索</span><input v-model="query" type="search" placeholder="类别 / 检查项 / blocker / trace"></label>
</div>
</template>
<DataTable :columns="columns" :rows="pageRows" :row-key="rowKey" empty-text="当前筛选没有 Gate 诊断行">
<template #cell-category="{ row }"><strong>{{ (row as GateDiagnosticRow).category || '-' }}</strong></template>
<template #cell-check="{ row }"><span>{{ (row as GateDiagnosticRow).check || '-' }}</span><small>{{ displayDate((row as GateDiagnosticRow).updatedAt) }}</small></template>
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as GateDiagnosticRow).statusKey || 'pending'">{{ statusLabel(row as GateDiagnosticRow) }}</span></template>
<template #cell-owner="{ row }"><code>{{ (row as GateDiagnosticRow).owner || '-' }}</code></template>
<template #cell-detail="{ row }"><span class="system-row-detail">{{ (row as GateDiagnosticRow).detail || '-' }}</span></template>
<template #cell-actions="{ row }"><button class="table-action" type="button" @click="selectedRow = row as GateDiagnosticRow">Detail</button></template>
</DataTable>
<template #pagination><Pagination v-model:page="page" :page-size="pageSize" :total="filteredRows.length" /></template>
</TablePageLayout>
<BaseDialog :open="selectedRow !== null" title="Gate failure drill-down" :description="selectedRow?.check || ''" wide @close="selectedRow = null">
<dl v-if="selectedRow" class="field-grid">
<div><dt>category</dt><dd>{{ selectedRow.category || '-' }}</dd></div>
<div><dt>status</dt><dd>{{ statusLabel(selectedRow) }}</dd></div>
<div><dt>owner</dt><dd>{{ selectedRow.owner || '-' }}</dd></div>
<div><dt>updated</dt><dd>{{ displayDate(selectedRow.updatedAt) }}</dd></div>
</dl>
<section v-if="selectedRow" class="system-drilldown">
<h3>关键细节</h3>
<p>{{ selectedRow.detail || '-' }}</p>
<h3>证据 / trace</h3>
<ul><li v-for="item in evidenceList(selectedRow)" :key="item"><code>{{ item }}</code></li></ul>
<h3>下一步</h3>
<p>{{ selectedRow.next || '-' }}</p>
</section>
</BaseDialog>
</template>
</section> </section>
</template> </template>
+106 -18
View File
@@ -1,29 +1,117 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { usageAPI } from "@/api"; import { systemAPI } from "@/api";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
import EmptyState from "@/components/common/EmptyState.vue"; import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue"; import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue"; import PageHeader from "@/components/common/PageHeader.vue";
import type { UsageSummary } from "@/types"; import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useTableLoader } from "@/composables/useTableLoader";
import type { WebPerformanceRow, WebPerformanceSummaryResponse } from "@/types";
const summary = ref<UsageSummary | null>(null); const columns: DataTableColumn[] = [
const loading = ref(false); { key: "metric", label: "Metric" },
const error = ref<string | null>(null); { key: "route", label: "Route" },
onMounted(async () => { { key: "count", label: "Count", align: "right" },
loading.value = true; { key: "p50", label: "P50", align: "right" },
const response = await usageAPI.performance(); { key: "p95", label: "P95", align: "right" },
summary.value = response.data; { key: "tone", label: "状态" }
error.value = response.ok ? null : response.error; ];
loading.value = false;
const payload = ref<WebPerformanceSummaryResponse | null>(null);
const table = useTableLoader<WebPerformanceRow>(async () => {
const response = await systemAPI.performanceSummary();
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
payload.value = response.data;
return response.data?.rows ?? [];
}); });
const summary = computed(() => payload.value?.summary ?? {});
const problems = computed(() => payload.value?.problems ?? []);
const webVitals = computed(() => payload.value?.webVitals ?? []);
const apiRoutes = computed(() => payload.value?.apiRoutes ?? []);
const longTasks = computed(() => payload.value?.longTasks ?? []);
const chartRows = computed(() => [
{ label: "Web Vitals", value: webVitals.value.length },
{ label: "API Timing", value: apiRoutes.value.length },
{ label: "Long Tasks", value: longTasks.value.length },
{ label: "Problems", value: problems.value.length }
]);
const maxChartValue = computed(() => Math.max(1, ...chartRows.value.map((row) => row.value)));
onMounted(() => void table.reload());
function rowKey(row: WebPerformanceRow): string {
return `${row.kind ?? 'kind'}:${row.metric ?? 'metric'}:${row.route ?? 'route'}:${row.method ?? 'method'}:${row.statusClass ?? 'status'}`;
}
function formatValue(row: WebPerformanceRow, key: "average" | "p50" | "p95"): string {
const value = Number(row[key]);
if (!Number.isFinite(value)) return "-";
if (row.unit === "score") return value.toFixed(3);
return `${Math.round(value * 1000)}ms`;
}
function formatCount(value?: number): string {
return Number.isFinite(Number(value)) ? String(value) : "0";
}
</script> </script>
<template> <template>
<section class="route-stack"> <section class="route-stack system-page performance-page">
<PageHeader eyebrow="System" title="性能监控" description="Cloud Web RUM/API latency 的页面骨架,保持轻量源码检查,不恢复旧 browser gate。" /> <PageHeader eyebrow="System" title="性能监控" description="RUM 已在 Vue 入口安装,页面汇总真实浏览器 navigation/Web Vital/API timing/long task 样本。" />
<LoadingState v-if="loading" /> <LoadingState v-if="table.loading.value && !table.rows.value.length" />
<EmptyState v-else-if="error" title="Performance 加载失败" :description="error" /> <section v-else-if="table.error.value" class="data-panel">
<EmptyState v-else-if="!summary?.rows?.length" title="Performance 空状态" description="等待 /v1/web-performance/summary 同源数据。" /> <EmptyState title="Performance 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
<pre v-else class="json-panel">{{ JSON.stringify(summary, null, 2) }}</pre> </section>
<template v-else>
<section class="system-summary-grid" aria-label="Performance summary">
<article class="metric-card"><span>Status</span><strong>{{ summary.status || 'waiting' }}</strong><small>{{ payload?.source || '-' }}</small></article>
<article class="metric-card"><span>Samples</span><strong>{{ formatCount(summary.sampleCount as number) }}</strong><small>{{ formatCount(summary.sampleSeries as number) }} series</small></article>
<article class="metric-card"><span>Routes</span><strong>{{ formatCount(summary.routeCount as number) }}</strong><small>{{ payload?.namespace || '-' }} / {{ payload?.gitopsTarget || '-' }}</small></article>
<article class="metric-card"><span>Problems</span><strong>{{ formatCount(summary.problemCount as number) }}</strong><small>{{ payload?.observedAt || '-' }}</small></article>
</section>
<section class="data-panel rum-chart-panel" aria-label="Performance chart">
<div v-for="item in chartRows" :key="item.label" class="rum-bar-row">
<span>{{ item.label }}</span>
<div><i :style="{ width: `${Math.max(6, (item.value / maxChartValue) * 100)}%` }" /></div>
<strong>{{ item.value }}</strong>
</div>
</section>
<TablePageLayout title="Problem rows" subtitle="按后端 summary 的 warn/blocked/outcome 聚合">
<template #actions><button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button></template>
<DataTable :columns="columns" :rows="problems.length ? problems : table.rows.value" :row-key="rowKey" empty-text="等待 RUM 样本浏览几个页面后刷新即可看到数据">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).method || 'GET' }} / {{ (row as WebPerformanceRow).statusClass || '-' }} / {{ (row as WebPerformanceRow).outcome || '-' }}</small></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem || '' }}</small></template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
</DataTable>
</TablePageLayout>
<section class="system-split-grid">
<TablePageLayout title="Web vitals" subtitle="navigation / LCP / CLS / FID">
<DataTable :columns="columns" :rows="webVitals" :row-key="rowKey" empty-text="暂无 Web Vital 样本">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p95="{ row }">{{ formatValue(row as WebPerformanceRow, 'p95') }}</template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
</DataTable>
</TablePageLayout>
<TablePageLayout title="API timing" subtitle="fetchJson/fetchText 同源 API 耗时">
<DataTable :columns="columns" :rows="apiRoutes" :row-key="rowKey" empty-text="暂无 API timing 样本">
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).method || 'GET' }} / {{ (row as WebPerformanceRow).statusClass || '-' }}</small></template>
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
<template #cell-p95="{ row }">{{ formatValue(row as WebPerformanceRow, 'p95') }}</template>
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
</DataTable>
</TablePageLayout>
</section>
</template>
</section> </section>
</template> </template>
+179 -16
View File
@@ -1,29 +1,192 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { api } from "@/api"; import { systemAPI, type SkillUploadFileInput } from "@/api";
import BaseDialog from "@/components/common/BaseDialog.vue";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
import EmptyState from "@/components/common/EmptyState.vue"; import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue"; import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue"; import PageHeader from "@/components/common/PageHeader.vue";
import type { SkillsResponse } from "@/types"; import Pagination from "@/components/common/Pagination.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useForm } from "@/composables/useForm";
import { useTableLoader } from "@/composables/useTableLoader";
import type { SkillFilePayload, SkillRecord, SkillTreeEntry, SkillsResponse } from "@/types";
const columns: DataTableColumn[] = [
{ key: "name", label: "Skill" },
{ key: "source", label: "来源" },
{ key: "version", label: "版本" },
{ key: "files", label: "文件", align: "right" },
{ key: "assembly", label: "AgentRun assembly" },
{ key: "actions", label: "操作", align: "right" }
];
const payload = ref<SkillsResponse | null>(null); const payload = ref<SkillsResponse | null>(null);
const loading = ref(false); const selectedSkill = ref<SkillRecord | null>(null);
const error = ref<string | null>(null); const tree = ref<SkillTreeEntry[]>([]);
onMounted(async () => { const treeError = ref<string | null>(null);
loading.value = true; const filePreview = ref<SkillFilePayload | null>(null);
const response = await api.skills(); const fileError = ref<string | null>(null);
const uploadOpen = ref(false);
const uploadName = ref("");
const uploadFiles = ref<SkillUploadFileInput[]>([]);
const uploadForm = useForm();
const page = ref(1);
const pageSize = 10;
const table = useTableLoader<SkillRecord>(async () => {
const response = await systemAPI.skills();
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
payload.value = response.data; payload.value = response.data;
error.value = response.ok ? null : response.error; return response.data?.skills ?? [];
loading.value = false;
}); });
const rows = computed(() => table.rows.value);
const pageRows = computed(() => rows.value.slice((page.value - 1) * pageSize, page.value * pageSize));
const uploadedCount = computed(() => rows.value.filter((row) => row.source === "uploaded").length);
const preinstalledCount = computed(() => rows.value.filter((row) => row.source === "preinstalled").length);
const assembly = computed(() => payload.value?.agentRunAssembly ?? {});
const assemblyResourceBundle = computed(() => (assembly.value.resourceBundle && typeof assembly.value.resourceBundle === "object" ? assembly.value.resourceBundle as Record<string, unknown> : {}));
const assemblyPrompt = computed(() => (assembly.value.promptAssembly && typeof assembly.value.promptAssembly === "object" ? assembly.value.promptAssembly as Record<string, unknown> : {}));
onMounted(() => void table.reload());
function rowKey(row: SkillRecord): string {
return row.id;
}
async function inspectSkill(row: SkillRecord): Promise<void> {
selectedSkill.value = row;
tree.value = [];
filePreview.value = null;
treeError.value = null;
fileError.value = null;
const treeResponse = await systemAPI.skillTree(row.id);
if (!treeResponse.ok) {
treeError.value = treeResponse.error ?? `HTTP ${treeResponse.status}`;
return;
}
tree.value = treeResponse.data?.tree ?? [];
await previewFile(row, "SKILL.md");
}
async function previewFile(row: SkillRecord, path = "SKILL.md"): Promise<void> {
fileError.value = null;
const response = await systemAPI.skillFile(row.id, path);
if (!response.ok) {
filePreview.value = null;
fileError.value = response.error ?? `HTTP ${response.status}`;
return;
}
filePreview.value = response.data?.file ?? { path, content: response.data?.content ?? "" };
}
async function onUploadInput(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
uploadFiles.value = await Promise.all(files.slice(0, 80).map(async (file) => ({
relativePath: file.webkitRelativePath || file.name,
contentText: await file.text()
})));
}
async function uploadSkill(): Promise<void> {
const ok = await uploadForm.submit(async () => {
const response = await systemAPI.uploadSkill(uploadName.value, uploadFiles.value);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
uploadOpen.value = false;
uploadName.value = "";
uploadFiles.value = [];
await table.reload();
const uploaded = response.data?.skill;
if (uploaded) await inspectSkill(uploaded);
}, "Skill uploaded");
if (!ok) return;
}
function displayDate(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function formatBytes(value?: number): string {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
</script> </script>
<template> <template>
<section class="route-stack"> <section class="route-stack system-page skills-page">
<PageHeader eyebrow="System" title="Skills" description="技能包上传、浏览和审阅的 Vue route 骨架。" /> <PageHeader eyebrow="System" title="Skills" description="技能包管理直接使用 /v1/skills、upload、tree 和 file preview 接口,展示 AgentRun assembly 状态。" />
<LoadingState v-if="loading" /> <LoadingState v-if="table.loading.value && !rows.length" />
<EmptyState v-else-if="error" title="Skills 加载失败" :description="error" /> <section v-else-if="table.error.value" class="data-panel">
<EmptyState v-else-if="!payload?.skills?.length" title="Skills 空状态" description="等待 /v1/skills 返回真实技能包。" /> <EmptyState title="Skills 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
<pre v-else class="json-panel">{{ JSON.stringify(payload, null, 2) }}</pre> </section>
<template v-else>
<section class="system-summary-grid" aria-label="Skills summary">
<article class="metric-card"><span>Total</span><strong>{{ rows.length }}</strong><small>{{ preinstalledCount }} preinstalled / {{ uploadedCount }} uploaded</small></article>
<article class="metric-card"><span>ResourceBundle</span><strong>{{ assemblyResourceBundle.kind || '-' }}</strong><small>{{ assemblyResourceBundle.skillsDir || '-' }}</small></article>
<article class="metric-card"><span>Prompt</span><strong>{{ assemblyPrompt.status || '-' }}</strong><small>valuesPrinted={{ assemblyPrompt.valuesPrinted === false ? 'false' : 'unknown' }}</small></article>
<article class="metric-card"><span>Roots</span><strong>{{ payload?.roots?.uploaded ? 'ready' : 'unknown' }}</strong><small>{{ payload?.roots?.codexAggregation || '-' }}</small></article>
</section>
<TablePageLayout title="Skill registry" subtitle="preinstalled + uploaded roots / tree / SKILL.md preview">
<template #actions>
<button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
<button class="table-action" type="button" @click="uploadOpen = true">Upload</button>
</template>
<DataTable :columns="columns" :rows="pageRows" :row-key="rowKey" empty-text="暂无可用 skill">
<template #cell-name="{ row }">
<strong>{{ (row as SkillRecord).name || (row as SkillRecord).id }}</strong>
<small>{{ (row as SkillRecord).description || '-' }}</small>
</template>
<template #cell-source="{ row }"><span class="status-pill" :data-status="(row as SkillRecord).source || 'pending'">{{ (row as SkillRecord).source || '-' }}</span></template>
<template #cell-version="{ row }"><code>{{ (row as SkillRecord).version || (row as SkillRecord).commitId || '-' }}</code><small>{{ displayDate((row as SkillRecord).updatedAt || (row as SkillRecord).createdAt) }}</small></template>
<template #cell-files="{ row }"><strong>{{ (row as SkillRecord).fileCount ?? 0 }}</strong><small>{{ formatBytes((row as SkillRecord).sizeBytes) }}</small></template>
<template #cell-assembly="{ row }"><code>{{ (row as SkillRecord).manifestPath || '-' }}</code></template>
<template #cell-actions="{ row }"><button class="table-action" type="button" @click="inspectSkill(row as SkillRecord)">Tree</button></template>
</DataTable>
<template #pagination><Pagination v-model:page="page" :page-size="pageSize" :total="rows.length" /></template>
</TablePageLayout>
<section class="system-split-grid skills-detail-grid">
<section class="data-panel skills-tree-panel">
<header class="panel-header"><h2>目录树</h2><span class="status-pill" :data-status="treeError ? 'error' : selectedSkill ? 'ready' : 'pending'">{{ selectedSkill?.name || '未选择' }}</span></header>
<p v-if="treeError" class="form-error">{{ treeError }}</p>
<p v-else-if="!selectedSkill" class="muted-box">选择一行 skill 查看 tree SKILL.md</p>
<ul v-else class="skill-tree-list">
<li v-for="entry in tree" :key="entry.path" :data-type="entry.type">
<button type="button" :disabled="entry.type !== 'file'" @click="selectedSkill && previewFile(selectedSkill, entry.path)">{{ entry.type === 'directory' ? 'dir' : 'file' }}</button>
<span>{{ entry.path }}</span>
<small>{{ entry.type === 'file' ? formatBytes(entry.sizeBytes ?? 0) : '' }}</small>
</li>
</ul>
</section>
<section class="data-panel skills-preview-panel">
<header class="panel-header"><h2>文件预览</h2><code>{{ filePreview?.path || 'SKILL.md' }}</code></header>
<p v-if="fileError" class="form-error">{{ fileError }}</p>
<pre v-else class="mini-log">{{ filePreview?.content || '等待选择 skill 或文件。' }}</pre>
</section>
</section>
</template>
<BaseDialog :open="uploadOpen" title="Upload skill" description="上传文本型 skill 目录;至少包含 SKILL.md。" wide @close="uploadOpen = false">
<form class="admin-form" @submit.prevent="uploadSkill">
<label><span>名称</span><input v-model="uploadName" type="text" placeholder="可选;默认读取 SKILL.md frontmatter"></label>
<label><span>目录或文件</span><input type="file" multiple webkitdirectory @change="onUploadInput"></label>
<p class="muted-box">已选择 {{ uploadFiles.length }} 个文本文件后端会剥离单层顶级目录并写入 uploaded skill root</p>
<p v-if="uploadForm.error.value" class="form-error">{{ uploadForm.error.value }}</p>
<p v-if="uploadForm.notice.value" class="form-notice">{{ uploadForm.notice.value }}</p>
</form>
<template #footer>
<button class="table-action" type="button" :disabled="uploadForm.loading.value" @click="uploadOpen = false">取消</button>
<button class="table-action" type="button" :disabled="uploadForm.loading.value || uploadFiles.length === 0" @click="uploadSkill">{{ uploadForm.loading.value ? '上传中' : '上传' }}</button>
</template>
</BaseDialog>
</section> </section>
</template> </template>