refactor(web): 重写非 Workbench 工业控制台视觉层

This commit is contained in:
root
2026-07-14 03:34:37 +02:00
parent b513a565bd
commit bd9c5bf9d1
13 changed files with 844 additions and 19 deletions
@@ -218,6 +218,46 @@ test("R6 MDTODO task rows expose opaque task references", () => {
assert.match(taskTreeSource, /:data-task-ref="task\.taskRef"/u);
});
test("R2 console rewrite owns the final visual layer and removes private letter glyphs", () => {
const mainSource = read("src/main.ts");
assert.match(mainSource, /console-shell\.css/u);
assert.match(mainSource, /console-r2\.css/u);
assert.doesNotMatch(mainSource, /console-legacy\.css/u);
const iconSource = read("src/components/console/ConsoleIcon.vue");
for (const icon of ["menu", "info", "logout", "empty", "project", "key", "provider", "skills"])
assert.match(iconSource, new RegExp(`\\b${icon}:`, "u"), `ConsoleIcon should define ${icon}`);
const migratedViews = [
"src/components/common/EmptyState.vue",
"src/views/SkillsView.vue",
"src/views/projects/ProjectsView.vue",
"src/views/admin/ProviderProfilesView.vue",
"src/views/user/ApiKeysView.vue",
];
for (const relativePath of migratedViews) {
const source = read(relativePath);
assert.match(source, /ConsoleIcon/u, `${relativePath} should use the shared icon system`);
assert.doesNotMatch(source, />\s*(?:SK|PJ|AI|KEY|HW)\s*</u, `${relativePath} should not render private letter glyphs`);
}
const r2Styles = read("src/styles/console-r2.css");
for (const selector of [".platform-sidebar", ".page-command-bar", ".console-status-strip", ".console-data-grid", ".pcb-card", ".mdtodo-workspace"])
assert.match(r2Styles, new RegExp(selector.replaceAll(".", "\\."), "u"), `R2 visual layer should own ${selector}`);
assert.match(r2Styles, /@media \(max-width: 1100px\)/u);
assert.match(r2Styles, /@media \(max-width: 760px\)/u);
assert.match(r2Styles, /@media \(max-width: 430px\)/u);
assert.match(r2Styles, /prefers-reduced-motion/u);
const collectionSource = read("src/components/common/ResourceCollection.vue");
assert.match(collectionSource, /VirtualList/u);
assert.match(collectionSource, /virtualizeAfter/u);
assert.match(collectionSource, /itemIcon/u);
const cardSource = read("src/components/common/EntityCard.vue");
assert.match(cardSource, /ConsoleIcon/u);
assert.match(cardSource, /entity-card-meta/u);
});
test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => {
const issueBody = readIssueBodyFixture();
assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u);
@@ -1,14 +1,17 @@
<script setup lang="ts">
import ApiErrorDiagnostic from "./ApiErrorDiagnostic.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
import type { ApiError, ErrorDiagnostic } from "@/types";
defineProps<{ title: string; description?: string; actionLabel?: string; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null }>();
withDefaults(defineProps<{ title: string; description?: string; actionLabel?: string; icon?: string; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null }>(), {
icon: "empty"
});
defineEmits<{ action: [] }>();
</script>
<template>
<section class="empty-state">
<div class="empty-state-mark">HW</div>
<div class="empty-state-mark" aria-hidden="true"><ConsoleIcon :name="icon" :size="20" /></div>
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
<ApiErrorDiagnostic v-if="apiError || diagnostic" :error="description" :api-error="apiError" :diagnostic="diagnostic" compact :show-message="false" />
@@ -4,12 +4,14 @@
<script setup lang="ts">
import StatusBadge from "./StatusBadge.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
const props = withDefaults(defineProps<{
title: string;
subtitle?: string;
status?: string;
statusLabel?: string;
icon?: string;
selected?: boolean;
disabled?: boolean;
interactive?: boolean;
@@ -17,6 +19,7 @@ const props = withDefaults(defineProps<{
subtitle: "",
status: "",
statusLabel: "",
icon: "",
selected: false,
disabled: false,
interactive: true
@@ -50,12 +53,16 @@ function onKeydown(event: KeyboardEvent): void {
@keydown="onKeydown"
>
<header class="entity-card-header">
<div class="entity-card-title-stack">
<strong>{{ title }}</strong>
<small v-if="subtitle">{{ subtitle }}</small>
<div class="entity-card-heading">
<span v-if="icon || $slots.icon" class="entity-card-icon" aria-hidden="true"><slot name="icon"><ConsoleIcon :name="icon" /></slot></span>
<div class="entity-card-title-stack">
<strong>{{ title }}</strong>
<small v-if="subtitle">{{ subtitle }}</small>
</div>
</div>
<StatusBadge v-if="status || statusLabel" :status="status" :label="statusLabel" />
</header>
<div v-if="$slots.meta" class="entity-card-meta"><slot name="meta" /></div>
<div v-if="$slots.default" class="entity-card-body"><slot /></div>
<footer v-if="$slots.footer" class="entity-card-footer" @click.stop><slot name="footer" /></footer>
</article>
@@ -4,6 +4,7 @@
<script setup lang="ts" generic="T extends object">
import EntityCard from "./EntityCard.vue";
import VirtualList from "./VirtualList.vue";
withDefaults(defineProps<{
items: T[];
@@ -11,22 +12,30 @@ withDefaults(defineProps<{
itemTitle?: (item: T) => string;
itemSubtitle?: (item: T) => string;
itemStatus?: (item: T) => string;
itemIcon?: (item: T) => string;
view?: "cards" | "list";
density?: "compact" | "comfortable";
selectedId?: string | null;
ariaLabel?: string;
emptyText?: string;
showControls?: boolean;
virtualizeAfter?: number;
virtualItemHeight?: number;
virtualHeight?: number | string;
}>(), {
itemTitle: undefined,
itemSubtitle: undefined,
itemStatus: undefined,
itemIcon: undefined,
view: "cards",
density: "comfortable",
selectedId: null,
ariaLabel: "资源集合",
emptyText: "暂无资源。",
showControls: true
showControls: true,
virtualizeAfter: 80,
virtualItemHeight: 56,
virtualHeight: "min(62vh, 680px)"
});
const emitEvent = defineEmits<{
@@ -55,7 +64,37 @@ function selectItem(item: T): void {
</div>
</div>
</header>
<div v-if="items.length" class="resource-collection-items" :data-view="view" :data-density="density" role="list" :aria-label="ariaLabel">
<VirtualList
v-if="items.length >= virtualizeAfter && view === 'list'"
class="resource-collection-virtual"
:items="items"
:item-key="itemKey"
:item-height="virtualItemHeight"
:height="virtualHeight"
:aria-label="ariaLabel"
>
<template #default="{ item }">
<div class="resource-collection-item resource-collection-item--virtual">
<slot
v-if="$slots.list"
name="list"
:item="item"
:selected="itemKey(item) === selectedId"
:select="() => selectItem(item)"
/>
<EntityCard
v-else
:title="itemTitle?.(item) || itemKey(item)"
:subtitle="itemSubtitle?.(item)"
:status="itemStatus?.(item)"
:icon="itemIcon?.(item)"
:selected="itemKey(item) === selectedId"
@select="selectItem(item)"
/>
</div>
</template>
</VirtualList>
<div v-else-if="items.length" class="resource-collection-items" :data-view="view" :data-density="density" role="list" :aria-label="ariaLabel">
<div v-for="item in items" :key="itemKey(item)" class="resource-collection-item" role="listitem">
<slot
v-if="view === 'list' && $slots.list"
@@ -69,6 +108,7 @@ function selectItem(item: T): void {
:title="itemTitle?.(item) || itemKey(item)"
:subtitle="itemSubtitle?.(item)"
:status="itemStatus?.(item)"
:icon="itemIcon?.(item)"
:selected="itemKey(item) === selectedId"
@select="selectItem(item)"
/>
@@ -84,6 +124,7 @@ function selectItem(item: T): void {
:title="itemTitle?.(item) || itemKey(item)"
:subtitle="itemSubtitle?.(item)"
:status="itemStatus?.(item)"
:icon="itemIcon?.(item)"
:selected="itemKey(item) === selectedId"
@select="selectItem(item)"
/>
@@ -86,6 +86,10 @@ const paths: Record<string, string[]> = {
"M12 18h.01",
"M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20",
],
menu: ["M4 7h16", "M4 12h16", "M4 17h16"],
info: ["M12 11v6", "M12 7h.01", "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20"],
logout: ["M10 5H5v14h5", "M14 8l4 4-4 4", "M18 12H9"],
empty: ["M5 7h14v12H5z", "M8 4h8", "M9 12h6", "M12 9v6"],
};
</script>
@@ -7,6 +7,7 @@ import { useRoute } from "vue-router";
import BaseDialog from "@/components/common/BaseDialog.vue";
import BaseDrawer from "@/components/common/BaseDrawer.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
import WorkbenchBuildSummary from "@/components/workbench/WorkbenchBuildSummary.vue";
import WorkbenchProbePanel from "@/components/workbench/WorkbenchProbePanel.vue";
import AppNavigation from "./AppNavigation.vue";
@@ -42,8 +43,8 @@ watch(showWorkbenchDiagnostics, (visible) => {
</aside>
<section class="platform-main">
<header class="platform-topbar">
<button class="icon-button desktop-sidebar-toggle" type="button" :aria-label="app.sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'" @click="app.sidebarCollapsed = !app.sidebarCollapsed"></button>
<button class="icon-button mobile-sidebar-toggle" type="button" aria-label="打开主导航" @click="app.mobileOpen = true"></button>
<button class="icon-button desktop-sidebar-toggle" type="button" :aria-label="app.sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'" @click="app.sidebarCollapsed = !app.sidebarCollapsed"><ConsoleIcon name="menu" /></button>
<button class="icon-button mobile-sidebar-toggle" type="button" aria-label="打开主导航" @click="app.mobileOpen = true"><ConsoleIcon name="menu" /></button>
<div class="topbar-status">
<span v-if="app.loading" class="topbar-progress" aria-live="polite"><span aria-hidden="true" /></span>
<span>同源 API</span>
@@ -51,10 +52,10 @@ watch(showWorkbenchDiagnostics, (visible) => {
</div>
<div class="topbar-actions">
<button v-if="showWorkbenchDiagnostics" id="workbench-diagnostics-toggle" class="workbench-diagnostics-toggle" type="button" aria-haspopup="dialog" @click="diagnosticsOpen = true">
<span aria-hidden="true">i</span>
<ConsoleIcon name="info" :size="16" />
<StatusBadge :status="workbench.chatPending ? 'running' : 'pending'" :label="workbench.chatPending ? '处理中' : '诊断'" />
</button>
<button class="btn btn-secondary" type="button" @click="auth.logout">退出</button>
<button class="btn btn-secondary" type="button" @click="auth.logout"><ConsoleIcon name="logout" :size="16" />退出</button>
</div>
</header>
<div class="platform-content">
+2 -1
View File
@@ -12,10 +12,11 @@ import { workbenchRuntimePolicy } from "@/config/workbench-runtime-policy";
import { installWebRum } from "@/utils/rum";
import "./styles/tokens.css";
import "./style.css";
import "./styles/console-legacy.css";
import "./styles/console-shell.css";
import "./styles/workbench.css";
import "./styles/console-pages.css";
import "./styles/console-foundation.css";
import "./styles/console-r2.css";
function initThemeClass(): void {
const savedTheme = localStorage.getItem("theme");
@@ -0,0 +1,724 @@
/* SPEC: PJ2026-010405 云端控制台,MDTODO hwlab-web-product-experience/R2。 */
/* Responsibility: 统一非 Workbench 页面的工业运营台壳层、密度、状态和响应式视觉。 */
:root {
--console-canvas: #dfe5e2;
--console-surface: #f5f6f2;
--console-surface-raised: #fbfcf8;
--console-surface-muted: #edf0ec;
--console-border: #bcc7c4;
--console-border-strong: #8fa09c;
--console-shadow-sm: 0 1px 0 rgb(255 255 255 / 65%), 0 1px 3px rgb(8 20 22 / 9%);
--console-shadow-md: 0 18px 48px rgb(8 20 22 / 22%);
--console-sidebar-width: 256px;
--console-sidebar-collapsed-width: 68px;
--console-topbar-height: 46px;
}
body {
background:
linear-gradient(rgb(17 25 28 / 3%) 1px, transparent 1px),
linear-gradient(90deg, rgb(17 25 28 / 3%) 1px, transparent 1px),
var(--console-canvas);
background-size: 24px 24px;
}
.platform-shell {
grid-template-columns: var(--console-sidebar-width) minmax(0, 1fr);
background: transparent;
}
.platform-shell.is-sidebar-collapsed {
grid-template-columns: var(--console-sidebar-collapsed-width) minmax(0, 1fr);
}
.platform-sidebar {
gap: 14px;
border-right-color: #263538;
background:
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent 120px),
#10191c;
padding: 14px 10px;
color: #dce7e4;
box-shadow: 8px 0 28px rgb(8 20 22 / 12%);
}
.brand-lockup {
min-height: 42px;
border-bottom: 1px solid #2b3b3e;
padding: 0 6px 12px;
}
.brand-mark {
width: 34px;
height: 34px;
border: 1px solid #37565a;
border-radius: 5px;
background: #18272a;
color: #59d0d0;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 3%);
}
.brand-lockup strong {
color: #f3f8f6;
font-family: var(--console-font-mono);
font-size: 13px;
letter-spacing: .08em;
}
.brand-lockup small {
color: #819492;
font-family: var(--console-font-mono);
font-size: 9px;
letter-spacing: .04em;
text-transform: uppercase;
}
.nav-groups {
gap: 10px;
}
.nav-section {
gap: 2px;
}
.nav-section h2 {
margin: 7px 8px 4px;
color: #718582;
font-family: var(--console-font-mono);
font-size: 9px;
letter-spacing: .11em;
}
.nav-item {
position: relative;
min-height: 34px;
gap: 9px;
border: 1px solid transparent;
border-radius: 4px;
padding: 5px 8px;
color: #b7c6c3;
font-size: 12px;
}
.nav-item::before {
position: absolute;
top: 7px;
bottom: 7px;
left: -10px;
width: 2px;
background: transparent;
content: "";
}
.nav-item:hover {
border-color: #2f4346;
background: #172427;
color: #f0f6f4;
}
.nav-icon {
width: 22px;
height: 22px;
border: 0;
border-radius: 3px;
background: transparent;
color: #809793;
}
.nav-item[data-active="true"] {
border-color: #31565a;
background: #173135;
color: #eaf8f6;
box-shadow: inset 0 0 0 1px rgb(70 204 204 / 4%);
}
.nav-item[data-active="true"]::before {
background: #43c7c7;
}
.nav-item[data-active="true"] .nav-icon {
background: transparent;
color: #54d2d0;
}
.platform-main {
grid-template-rows: var(--console-topbar-height) minmax(0, 1fr);
}
.platform-topbar {
gap: 8px;
border-bottom-color: var(--console-border-strong);
background: rgb(245 247 244 / 94%);
padding: 0 14px;
backdrop-filter: blur(12px);
}
.platform-topbar .icon-button,
.platform-topbar .btn {
min-height: 30px;
height: 30px;
border-radius: 4px;
}
.topbar-status {
gap: 8px;
font-family: var(--console-font-mono);
}
.topbar-status::before {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--console-green-700);
box-shadow: 0 0 0 3px var(--console-green-100);
content: "";
}
.topbar-status span {
color: var(--console-graphite-600);
font-size: 9px;
letter-spacing: .07em;
text-transform: uppercase;
}
.topbar-status strong {
overflow: hidden;
color: var(--console-graphite-800);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-content {
padding: 14px clamp(12px, 1.5vw, 24px) 24px;
scrollbar-gutter: stable;
}
.route-stack,
.hwpod-console,
.mdtodo-page {
gap: 12px;
}
.page-command-bar {
position: relative;
min-height: 76px;
align-items: center;
border: 1px solid var(--console-border);
border-radius: 6px;
background:
linear-gradient(90deg, rgb(0 131 138 / 6%), transparent 34%),
var(--console-surface-raised);
padding: 12px 14px 12px 18px;
box-shadow: var(--console-shadow-sm);
}
.page-command-bar::before {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
border-radius: 5px 0 0 5px;
background: var(--console-cyan-600);
content: "";
}
.page-command-bar[data-sticky="true"] {
top: 8px;
border-bottom: 1px solid var(--console-border);
background: rgb(251 252 248 / 96%);
}
.page-command-copy {
gap: 2px;
}
.page-command-eyebrow {
color: var(--console-cyan-700);
font-size: 9px;
letter-spacing: .12em;
}
.page-command-title {
font-size: clamp(20px, 2vw, 26px);
font-weight: 720;
letter-spacing: -.018em;
}
.page-command-description {
color: var(--console-graphite-600);
font-size: 12px;
}
.console-status-strip {
gap: 0;
border-radius: 5px;
background: var(--console-surface-raised);
box-shadow: var(--console-shadow-sm);
}
.console-status-item {
position: relative;
min-width: 132px;
min-height: 46px;
border-right: 1px solid var(--console-border);
background: transparent;
padding: 7px 10px 7px 15px;
}
.console-status-item:last-child {
border-right: 0;
}
.console-status-item::before {
position: absolute;
top: 10px;
bottom: 10px;
left: 7px;
width: 2px;
height: auto;
border-radius: 0;
}
.console-status-item dt {
font-size: 9px;
}
.console-status-item dd {
font-family: var(--console-font-mono);
font-size: 12px;
}
.data-panel,
.table-page-layout,
.console-section-frame,
.operations-workspace,
.bounded-workspace,
.entity-index,
.operations-inspector-panel,
.metric-card,
.empty-state {
border-color: var(--console-border);
border-radius: 6px;
background: var(--console-surface-raised);
box-shadow: var(--console-shadow-sm);
}
.data-panel,
.table-page-layout,
.admin-crud-panel {
padding: 12px;
}
.panel-header,
.table-page-header,
.operations-inspector-header,
.entity-index-toolbar {
border-bottom-color: var(--console-border);
}
.panel-header h2,
.table-page-header h2,
.operations-inspector-header h2 {
color: var(--console-graphite-900);
font-size: 13px;
font-weight: 760;
}
.btn,
.icon-button,
.table-action,
.segmented-control,
.segmented-control button,
.view-mode-switch,
.view-mode-switch button,
.console-tabs,
.console-tabs button {
border-radius: 4px;
}
.btn,
.table-action,
.segmented-control button,
.view-mode-switch button,
.console-tabs button {
font-family: var(--console-font-mono);
font-size: 10px;
letter-spacing: .02em;
}
.btn-primary,
.table-action.primary {
border-color: #006b70;
background: #007b80;
color: #fff;
}
.btn-primary:hover,
.table-action.primary:hover:not(:disabled) {
background: #00666b;
}
.btn-secondary,
.icon-button,
.table-action,
.segmented-control button,
.view-mode-switch button,
.console-tabs button {
border-color: var(--console-border-strong);
background: var(--console-surface-raised);
color: var(--console-graphite-700);
}
.input,
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea {
border-color: var(--console-border-strong);
border-radius: 4px;
background: #fff;
}
.input:focus,
input:focus,
select:focus,
textarea:focus,
button:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid color-mix(in srgb, var(--console-focus) 55%, transparent);
outline-offset: 2px;
}
.console-data-grid-wrap,
.data-table-wrap {
border-color: var(--console-border);
border-radius: 4px;
}
.console-data-grid,
.data-table {
font-size: 11px;
}
.console-data-grid thead,
.data-table thead {
background: #e5eae7;
}
.console-data-grid th,
.data-table th {
color: var(--console-graphite-700);
font-family: var(--console-font-mono);
font-size: 9px;
font-weight: 760;
letter-spacing: .06em;
text-transform: uppercase;
}
.console-data-grid td,
.console-data-grid th,
.data-table td,
.data-table th {
border-color: #d5dcda;
}
.console-data-grid tbody tr:nth-child(even),
.data-table tbody tr:nth-child(even) {
background: #f5f7f4;
}
.console-data-grid tbody tr:hover,
.data-table tbody tr:hover {
background: var(--console-cyan-100);
}
.resource-collection-items[data-view="cards"] {
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
}
.entity-card-heading {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 9px;
}
.entity-card-icon {
display: inline-grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
place-items: center;
border: 1px solid #80aaa7;
border-radius: 4px;
background: #dcecea;
color: #146866;
}
.entity-card-meta {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 5px 10px;
color: var(--console-graphite-600);
font-family: var(--console-font-mono);
font-size: 9px;
}
.resource-collection-virtual {
border: 1px solid var(--console-border);
border-radius: 4px;
background: var(--console-surface-raised);
}
.resource-collection-item--virtual {
height: 100%;
border-bottom: 1px solid #d8dfdd;
padding: 4px;
}
.resource-collection-item--virtual .entity-card {
height: 100%;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 6px 9px;
}
.entity-card,
.project-card,
.skill-card,
.provider-card,
.api-key-card,
.pcb-card,
.node-card {
border-color: var(--console-border);
border-radius: 5px;
background:
linear-gradient(135deg, rgb(0 131 138 / 5%), transparent 46%),
var(--console-surface-raised);
box-shadow: var(--console-shadow-sm);
}
.entity-card[data-interactive="true"]:hover,
.project-card:hover,
.skill-card:hover,
.provider-card:hover,
.api-key-card:hover {
border-color: var(--console-cyan-600);
box-shadow: 0 8px 22px rgb(11 31 33 / 11%);
transform: translateY(-1px);
}
.empty-state {
min-height: 148px;
align-content: center;
justify-items: center;
padding: 24px;
text-align: center;
}
.empty-state-mark,
.skill-glyph,
.project-glyph,
.api-key-glyph,
.provider-glyph {
display: inline-grid;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid #6faaa7;
border-radius: 5px;
background: #d9eeeb;
color: #126764;
font-size: 0;
}
.provider-glyph {
cursor: pointer;
}
.status-pill,
.status-badge,
.pm-status,
.task-state,
.stage-status {
border-radius: 3px;
font-family: var(--console-font-mono);
font-size: 9px;
letter-spacing: .04em;
}
.mini-log,
.json-panel,
.help-body,
.content-viewer pre,
.source-output,
.report-markdown {
border-color: #334649;
border-radius: 4px;
background: #111b1e;
color: #d9e7e4;
font-family: var(--console-font-mono);
}
.hwpod-workspace,
.mdtodo-workspace {
min-height: min(72vh, 760px);
}
.pcb-card {
position: relative;
overflow: hidden;
}
.pcb-card::after {
position: absolute;
right: -30px;
bottom: -34px;
width: 130px;
height: 100px;
border: 1px solid rgb(0 131 138 / 18%);
border-radius: 50%;
box-shadow: 0 0 0 16px rgb(0 131 138 / 4%), 0 0 0 32px rgb(0 131 138 / 3%);
content: "";
pointer-events: none;
}
.pcb-trace,
.pcb-grid-line {
color: var(--console-cyan-600);
}
.mdtodo-workspace,
.document-workspace,
.report-viewer,
.task-index {
border-color: var(--console-border-strong);
background: var(--console-surface-raised);
}
@media (max-width: 1100px) {
:root {
--console-sidebar-width: 224px;
}
.platform-content {
padding: 10px;
}
.page-command-bar {
grid-template-columns: minmax(0, 1fr);
}
.page-command-actions {
justify-content: flex-start;
}
.overview-grid,
.settings-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.platform-shell,
.platform-shell.is-sidebar-collapsed {
grid-template-columns: minmax(0, 1fr);
}
.platform-sidebar,
.desktop-sidebar-toggle {
display: none;
}
.platform-topbar {
padding: 0 8px;
}
.platform-content {
padding: 8px;
}
.page-command-bar {
min-height: 0;
padding: 10px 10px 10px 15px;
}
.page-command-title {
font-size: 19px;
}
.page-command-description {
font-size: 11px;
}
.console-status-item {
min-width: 118px;
}
.overview-grid,
.settings-grid,
.usage-panels,
.field-grid,
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
.resource-collection-items[data-view="cards"] {
grid-template-columns: minmax(0, 1fr);
}
.hwpod-workspace,
.mdtodo-workspace {
min-height: 0;
}
}
@media (max-width: 430px) {
.topbar-status span,
.topbar-actions .btn span {
display: none;
}
.topbar-actions .btn {
padding: 0 8px;
}
.page-command-actions,
.page-command-filters,
.table-page-actions,
.table-actions-inline {
align-items: stretch;
}
.page-command-actions > *,
.page-command-filters > * {
max-width: 100%;
}
.console-status-strip {
scroll-snap-type: x proximity;
}
.console-status-item {
flex-basis: 136px;
scroll-snap-align: start;
}
}
@media (prefers-reduced-motion: reduce) {
.entity-card,
.project-card,
.skill-card,
.provider-card,
.api-key-card,
.pcb-card,
.node-card {
transform: none !important;
}
}
@@ -1,5 +1,5 @@
/* SPEC: PJ2026-010405 云端控制台. */
/* Implementation reference: draft-2026-07-13-p0-cloud-console. */
/* Responsibility: 应用壳、认证入口和 Workbench 共享诊断表面。 */
.platform-shell {
display: grid;
height: 100dvh;
+2 -1
View File
@@ -13,6 +13,7 @@ import type { DataGridColumn } from "@/components/common/DataGrid.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import ResourceCollection from "@/components/common/ResourceCollection.vue";
import VirtualList from "@/components/common/VirtualList.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useForm } from "@/composables/useForm";
@@ -235,7 +236,7 @@ function routeFilePath(): string {
</DataGrid>
<ResourceCollection v-else-if="filteredRows.length" :items="filteredRows" :item-key="rowKey" view="cards" :selected-id="selectedSkill?.id" :show-controls="false" aria-label="Skill 卡片" @select="inspectSkill">
<template #card="{ item: row }"><article class="skill-card" :data-selected="selectedSkill?.id === row.id">
<header><span class="skill-glyph" aria-hidden="true">SK</span><div><strong>{{ row.name || row.id }}</strong><small>{{ row.description || '未提供描述' }}</small></div><span class="status-pill" :data-status="row.source || 'pending'">{{ row.source || '-' }}</span></header>
<header><span class="skill-glyph" aria-hidden="true"><ConsoleIcon name="skills" /></span><div><strong>{{ row.name || row.id }}</strong><small>{{ row.description || '未提供描述' }}</small></div><span class="status-pill" :data-status="row.source || 'pending'">{{ row.source || '-' }}</span></header>
<dl><div><dt>版本</dt><dd>{{ row.version || row.commitId || '-' }}</dd></div><div><dt>文件</dt><dd>{{ row.fileCount ?? 0 }} / {{ formatBytes(row.sizeBytes) }}</dd></div><div><dt>更新</dt><dd>{{ displayDate(row.updatedAt || row.createdAt) }}</dd></div></dl>
<footer><code>{{ row.manifestPath || 'manifest unavailable' }}</code><button class="table-action" type="button" @click="inspectSkill(row)">查看目录 </button></footer>
</article></template>
@@ -13,6 +13,7 @@ import DataGrid from "@/components/common/DataGrid.vue";
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import ResourceCollection from "@/components/common/ResourceCollection.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useForm } from "@/composables/useForm";
@@ -316,7 +317,7 @@ function restoreSelectedProfile(): void {
</DataGrid>
<ResourceCollection v-else-if="filteredRows.length" :items="filteredRows" :item-key="rowKey" view="cards" :selected-id="selectedProfile?.profile" :show-controls="false" aria-label="Provider Profile 卡片" @select="selectProfile">
<template #card="{ item: row }"><article class="provider-card" :data-selected="selectedProfile?.profile === row.profile">
<header><button class="provider-glyph" type="button" @click="selectProfile(row)">AI</button><div><button class="provider-name" type="button" @click="selectProfile(row)">{{ row.profile }}</button><small>{{ row.secretRefText }}</small></div><span class="status-pill" :data-status="row.configured ? 'active' : 'disabled'">{{ row.configured ? 'configured' : 'missing' }}</span></header>
<header><button class="provider-glyph" type="button" :aria-label="`选择 ${row.profile}`" @click="selectProfile(row)"><ConsoleIcon name="provider" /></button><div><button class="provider-name" type="button" @click="selectProfile(row)">{{ row.profile }}</button><small>{{ row.secretRefText }}</small></div><span class="status-pill" :data-status="row.configured ? 'active' : 'disabled'">{{ row.configured ? 'configured' : 'missing' }}</span></header>
<dl><div><dt>Fingerprint</dt><dd>key {{ row.credentialHashSuffix || '-' }} / cfg {{ row.configHashSuffix || '-' }}</dd></div><div><dt>Bridge</dt><dd>{{ row.bridgeText || '-' }}</dd></div><div><dt>Validation</dt><dd>{{ row.validationStatus || 'not run' }}<small v-if="row.failureKind">{{ row.failureKind }}</small></dd></div></dl>
<footer class="table-actions-inline"><button class="table-action" type="button" @click="openCredential(row)">Key</button><button class="table-action" type="button" @click="openConfig(row)">Config</button><button class="table-action" type="button" @click="openTest(row)">测试</button><button class="table-action" type="button" :disabled="validatingProfile !== null" @click="validateProfile(row)">{{ validatingProfile === row.profile ? '验证中' : 'Validate' }}</button><button class="table-action danger" type="button" @click="openRemove(row)">Remove</button></footer>
</article></template>
@@ -10,6 +10,7 @@ import { projectManagementAPI, type MdtodoTaskRecord, type ProjectNavigationResp
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import ResourceCollection from "@/components/common/ResourceCollection.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import StatusStrip from "@/components/layout/StatusStrip.vue";
@@ -258,14 +259,14 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
<ResourceCollection v-else :items="filteredProjects" :item-key="projectKey" :item-title="projectTitle" :item-subtitle="projectSubtitle" :item-status="projectStatus" :view="viewMode" :density="density" :selected-id="selectedProject?.projectId" :show-controls="false" aria-label="项目集合" @select="openProject">
<template #card="{ item: project }">
<RouterLink class="project-card" :class="{ selected: selectedProject?.projectId === project.projectId }" :data-project-id="project.projectId" :to="{ name: 'ProjectDetail', params: { projectId: project.projectId }, query: route.query }">
<div class="project-card-main"><span class="project-glyph" aria-hidden="true">PJ</span><span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span><div><h3>{{ projectTitle(project) }}</h3><p>{{ project.projectId }}</p></div></div>
<div class="project-card-main"><span class="project-glyph" aria-hidden="true"><ConsoleIcon name="project" /></span><span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span><div><h3>{{ projectTitle(project) }}</h3><p>{{ project.projectId }}</p></div></div>
<div class="project-source-chips" aria-label="项目 source"><span v-for="source in projectSources(project)" :key="source.sourceId" class="source-chip" :data-status="source.status || 'unknown'">{{ source.displayName || source.sourceId }} · {{ source.status || 'unknown' }}</span><span v-if="!projectSources(project).length" class="source-chip muted">无可见 source</span></div>
<span class="project-open-hint">查看详情 </span>
</RouterLink>
</template>
<template #list="{ item: project }">
<RouterLink class="project-card" :class="{ selected: selectedProject?.projectId === project.projectId }" :data-project-id="project.projectId" :to="{ name: 'ProjectDetail', params: { projectId: project.projectId }, query: route.query }">
<div class="project-card-main"><span class="project-glyph" aria-hidden="true">PJ</span><span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span><div><h3>{{ projectTitle(project) }}</h3><p>{{ project.projectId }}</p></div></div>
<div class="project-card-main"><span class="project-glyph" aria-hidden="true"><ConsoleIcon name="project" /></span><span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span><div><h3>{{ projectTitle(project) }}</h3><p>{{ project.projectId }}</p></div></div>
<div class="project-source-chips" aria-label="项目 source"><span v-for="source in projectSources(project)" :key="source.sourceId" class="source-chip" :data-status="source.status || 'unknown'">{{ source.displayName || source.sourceId }} · {{ source.status || 'unknown' }}</span><span v-if="!projectSources(project).length" class="source-chip muted">无可见 source</span></div>
<span class="project-open-hint">查看详情 </span>
</RouterLink>
@@ -9,6 +9,7 @@ import BaseDialog from "@/components/common/BaseDialog.vue";
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import ResourceCollection from "@/components/common/ResourceCollection.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import StatusStrip from "@/components/layout/StatusStrip.vue";
@@ -197,7 +198,7 @@ function formatDate(value: unknown): string {
<ResourceCollection v-else :items="filteredKeys" :item-key="keyId" :item-title="keyTitle" :item-subtitle="keySubtitle" :item-status="keyStatus" :view="viewMode" :density="density" :selected-id="selectedKey?.id" :show-controls="false" aria-label="API Key 集合" @select="selectKey">
<template #card="{ item: key }">
<RouterLink class="api-key-card" :class="{ selected: selectedKey?.id === key.id }" :to="{ name: 'ApiKeyDetail', params: { keyId: key.id }, query: route.query }">
<span class="api-key-glyph" aria-hidden="true">KEY</span>
<span class="api-key-glyph" aria-hidden="true"><ConsoleIcon name="key" /></span>
<div class="api-key-name"><strong>{{ key.name || key.id }}</strong><code>{{ key.id }}</code></div>
<code class="api-key-prefix">{{ key.prefix || '-' }}</code>
<span class="status-pill" :data-status="key.revokedAt ? 'disabled' : 'active'">{{ key.revokedAt ? 'revoked' : 'active' }}</span>
@@ -207,7 +208,7 @@ function formatDate(value: unknown): string {
</template>
<template #list="{ item: key }">
<RouterLink class="api-key-card" :class="{ selected: selectedKey?.id === key.id }" :to="{ name: 'ApiKeyDetail', params: { keyId: key.id }, query: route.query }">
<span class="api-key-glyph" aria-hidden="true">KEY</span>
<span class="api-key-glyph" aria-hidden="true"><ConsoleIcon name="key" /></span>
<div class="api-key-name"><strong>{{ key.name || key.id }}</strong><code>{{ key.id }}</code></div>
<code class="api-key-prefix">{{ key.prefix || '-' }}</code>
<span class="status-pill" :data-status="key.revokedAt ? 'disabled' : 'active'">{{ key.revokedAt ? 'revoked' : 'active' }}</span>