38 KiB
System Extension Workbench Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将 系统管理 -> 系统拓展 从纵向堆叠的旧式配置页,翻新为正式、紧凑、工业质感的“系统设置工作台”,不改变现有业务字段、接口和配置语义。
Architecture: 前端单页内重构:保留 SystemExtensionView.vue 的现有 API 调用和表单状态,新增顶部状态条、左侧配置目录、右侧单模块编辑区、底部固定保存栏。CSS 只新增/替换系统拓展专属 class,避免影响 DAG、仓库单据、权限脑图等近期未提交改动。
Tech Stack: Vue 3 <script setup>、现有 fetchResource/postResource/putResource API helper、现有 TableControls / PaginationBar、源码级 Node test、全局 frontend/src/styles/main.css。
Current Constraints
- 当前工作区已有未提交 DAG 改动:
backend/app/api/routes/dashboard.py、frontend/src/views/DashboardView.vue、frontend/src/styles/main.css,以及若干未跟踪 DAG 文件。 - 本计划执行时不得回滚、覆盖、格式化无关文件。
frontend/src/styles/main.css已有 DAG 未提交样式,执行时只编辑系统拓展相关选择器附近或追加新的系统拓展选择器。- 本次不新增后端接口,不改数据库,不改变“广播通知 / 原材料库存批次号前缀 / 是否对接智能报工小程序 / AI 助手配置”的保存逻辑。
- AI 助手“测试连接”本次不做,因为现有后端没有对应接口,避免为了视觉翻新引入后端行为变更。
File Structure
- Create:
frontend/src/views/SystemExtensionView.test.js - Modify:
frontend/src/views/SystemExtensionView.vue - Modify:
frontend/src/styles/main.css
Visual Target
页面最终结构:
系统拓展
广播启用数 | 库存批次前缀 | 报工小程序状态 | AI助手状态
+----------------------+---------------------------------------------+
| 广播通知 | 当前模块标题 / 状态 / 表单 / 列表 / 预览 |
| 库存批次规则 | |
| 生产对接模式 | |
| AI 助手配置 | |
+----------------------+---------------------------------------------+
右下角固定保存栏:放弃修改 | 保存配置
交互规则:
- 进入页面默认选中
广播通知。 - 左侧目录点击后只显示当前模块,不再让四组配置同时堆在页面里。
- 有未保存修改时才显示右下角固定保存栏。
- 当前模块保存成功后刷新对应状态条。
- 顶部不再出现
ADMIN ONLY、大面积解释卡片、广播通知与 AI 助手配置中心这种 demo 感强的 hero。 - 状态字段使用清晰色彩:启用绿色、暂停灰色、重要红色、预警橙色、信息蓝色。
- 响应式下,左侧目录变成横向滚动 tab,右侧模块撑满宽度。
Task 1: Add Source-Level UI Contract Test
Files:
-
Create:
frontend/src/views/SystemExtensionView.test.js -
Step 1: Write the failing test
Create frontend/src/views/SystemExtensionView.test.js with:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "SystemExtensionView.vue"), "utf8");
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
describe("SystemExtensionView workbench redesign", () => {
it("removes the old hero explanation card and admin-only copy", () => {
assert.doesNotMatch(source, /extension-hero/);
assert.doesNotMatch(source, /extension-status-card/);
assert.doesNotMatch(source, /广播通知与 AI 助手配置中心/);
assert.doesNotMatch(source, /ADMIN ONLY/);
});
it("renders a compact system extension workbench shell", () => {
[
"system-extension-workbench",
"extension-status-strip",
"extension-config-shell",
"extension-nav-rail",
"extension-module-panel",
"extension-save-dock"
].forEach((className) => {
assert.match(source, new RegExp(className));
assert.match(mainCss, new RegExp(`\\\\.${className}`));
});
});
it("exposes the four configuration modules as a navigation directory", () => {
["广播通知", "库存批次规则", "生产对接模式", "AI 助手配置"].forEach((label) => {
assert.match(source, new RegExp(label));
});
assert.match(source, /activeSection/);
assert.match(source, /extensionSections/);
});
it("uses a fixed save dock with explicit save and reset actions", () => {
assert.match(source, /放弃修改/);
assert.match(source, /保存配置/);
assert.match(source, /saveActiveSection/);
assert.match(source, /resetActiveSection/);
assert.match(source, /activeSectionDirty/);
});
it("keeps existing system extension API endpoints unchanged", () => {
[
"/system-extension/broadcasts",
"/system-extension/assistant-config",
"/system-extension/raw-material-lot-prefix",
"/system-extension/smart-operation-report"
].forEach((endpoint) => {
assert.match(source, new RegExp(endpoint.replaceAll("/", "\\\\/")));
});
});
});
- Step 2: Run test to verify it fails
Run:
cd frontend
node --test src/views/SystemExtensionView.test.js
Expected:
not ok ... removes the old hero explanation card and admin-only copy
not ok ... renders a compact system extension workbench shell
The test should fail because the existing page still contains extension-hero and does not contain the new workbench classes.
Task 2: Refactor Component State for Workbench Navigation and Dirty Save
Files:
-
Modify:
frontend/src/views/SystemExtensionView.vue -
Step 1: Update Vue imports
Change:
import { onMounted, reactive, ref } from "vue";
To:
import { computed, onMounted, reactive, ref } from "vue";
- Step 2: Add navigation state after existing refs
Add after:
const editingBroadcastId = ref(null);
This block:
const activeSection = ref("broadcast");
const sectionSnapshots = reactive({
broadcast: "",
rawLotPrefix: "",
productionMode: "",
assistant: ""
});
const extensionSections = [
{
key: "broadcast",
eyebrow: "Broadcast",
title: "广播通知",
desc: "维护右上角小喇叭通知",
accent: "blue"
},
{
key: "rawLotPrefix",
eyebrow: "Lot Rule",
title: "库存批次规则",
desc: "维护原材料库存批次号前缀",
accent: "steel"
},
{
key: "productionMode",
eyebrow: "Production",
title: "生产对接模式",
desc: "控制是否对接智能报工小程序",
accent: "green"
},
{
key: "assistant",
eyebrow: "AI Assistant",
title: "AI 助手配置",
desc: "维护 LLM 供应商、模型与提示词",
accent: "orange"
}
];
- Step 3: Add snapshot helpers after
formatAttendancePoints
Add:
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
function serializeValue(value) {
return JSON.stringify(value);
}
function getSectionState(sectionKey) {
if (sectionKey === "broadcast") {
return {
editingBroadcastId: editingBroadcastId.value,
...clonePlain(broadcastForm)
};
}
if (sectionKey === "rawLotPrefix") {
return clonePlain(rawLotPrefixForm);
}
if (sectionKey === "productionMode") {
return clonePlain(smartOperationReportForm);
}
if (sectionKey === "assistant") {
return {
...clonePlain(assistantForm),
api_key: ""
};
}
return {};
}
function captureSectionSnapshot(sectionKey) {
sectionSnapshots[sectionKey] = serializeValue(getSectionState(sectionKey));
}
function captureAllSectionSnapshots() {
extensionSections.forEach((section) => {
captureSectionSnapshot(section.key);
});
}
- Step 4: Add computed status and dirty state before
loadAll
Add:
const activeSectionMeta = computed(() => {
return extensionSections.find((section) => section.key === activeSection.value) || extensionSections[0];
});
const activeBroadcastCount = computed(() => {
return broadcasts.value.filter((item) => item.status === "ACTIVE").length;
});
const assistantStateLabel = computed(() => {
if (assistantConfig.value.enabled && assistantConfig.value.api_key_configured) {
return "已启用";
}
if (assistantConfig.value.api_key_configured) {
return "密钥已配置";
}
return "占位模式";
});
const statusCards = computed(() => [
{
label: "广播启用",
value: `${activeBroadcastCount.value} 条`,
tone: activeBroadcastCount.value ? "success" : "muted"
},
{
label: "库存批次前缀",
value: rawLotPrefixConfig.value.config_value || rawLotPrefixForm.config_value || "YL",
tone: "info"
},
{
label: "报工小程序",
value: smartOperationReportForm.enabled ? "已对接" : "未对接",
tone: smartOperationReportForm.enabled ? "success" : "warning"
},
{
label: "AI 助手",
value: assistantStateLabel.value,
tone: assistantConfig.value.enabled ? "success" : "muted"
}
]);
const activeSectionDirty = computed(() => {
return sectionSnapshots[activeSection.value] !== serializeValue(getSectionState(activeSection.value));
});
- Step 5: Add active-section save and reset helpers before
onMounted
Add:
async function saveActiveSection() {
if (activeSection.value === "broadcast") {
await saveBroadcast();
captureSectionSnapshot("broadcast");
return;
}
if (activeSection.value === "rawLotPrefix") {
await saveRawLotPrefixConfig();
captureSectionSnapshot("rawLotPrefix");
return;
}
if (activeSection.value === "productionMode") {
await saveSmartOperationReportConfig();
captureSectionSnapshot("productionMode");
return;
}
if (activeSection.value === "assistant") {
await saveAssistantConfig();
captureSectionSnapshot("assistant");
}
}
function resetActiveSection() {
if (activeSection.value === "broadcast") {
resetBroadcastForm();
captureSectionSnapshot("broadcast");
return;
}
if (activeSection.value === "rawLotPrefix") {
applyRawLotPrefixConfig(rawLotPrefixConfig.value);
captureSectionSnapshot("rawLotPrefix");
return;
}
if (activeSection.value === "productionMode") {
applySmartOperationReportConfig(smartOperationReportConfig.value);
captureSectionSnapshot("productionMode");
return;
}
if (activeSection.value === "assistant") {
applyAssistantConfig(assistantConfig.value);
captureSectionSnapshot("assistant");
}
}
- Step 6: Update save functions to refresh snapshots
At the end of successful saveBroadcast, after:
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
Add:
captureSectionSnapshot("broadcast");
At the end of successful saveAssistantConfig, after feedback:
feedbackMessage.value = "AI 机器人配置已保存";
Add:
captureSectionSnapshot("assistant");
At the end of successful saveRawLotPrefixConfig, after feedback:
feedbackMessage.value = "原材料库存批次号前缀已保存";
Add:
captureSectionSnapshot("rawLotPrefix");
At the end of successful saveSmartOperationReportConfig, after feedback:
feedbackMessage.value = "生产模式配置已保存";
Add:
captureSectionSnapshot("productionMode");
- Step 7: Capture initial snapshots after loading
Change onMounted from:
onMounted(async () => {
await loadAll();
});
To:
onMounted(async () => {
await loadAll();
captureAllSectionSnapshots();
});
- Step 8: Run the source test
Run:
cd frontend
node --test src/views/SystemExtensionView.test.js
Expected:
not ok ... removes the old hero explanation card and admin-only copy
not ok ... renders a compact system extension workbench shell
The test still fails because the template and CSS have not been refactored yet.
Task 3: Replace Old Stacked Template with Workbench Layout
Files:
-
Modify:
frontend/src/views/SystemExtensionView.vue -
Step 1: Replace the entire
<template>block
Replace the current <template>...</template> with:
<template>
<section class="system-extension-workbench">
<header class="extension-workbench-head">
<div>
<p class="eyebrow">系统管理</p>
<h2>系统拓展</h2>
</div>
<div class="extension-status-strip" aria-label="系统拓展状态概览">
<article
v-for="card in statusCards"
:key="card.label"
class="extension-status-pill"
:class="`extension-status-${card.tone}`"
>
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
</article>
</div>
</header>
<div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
<div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
<section class="extension-config-shell">
<nav class="extension-nav-rail" aria-label="系统拓展配置目录">
<button
v-for="section in extensionSections"
:key="section.key"
class="extension-nav-button"
:class="[{ active: activeSection === section.key }, `extension-nav-${section.accent}`]"
type="button"
@click="activeSection = section.key"
>
<span>{{ section.eyebrow }}</span>
<strong>{{ section.title }}</strong>
<small>{{ section.desc }}</small>
</button>
</nav>
<main class="extension-module-panel">
<div class="extension-module-head">
<div>
<p class="eyebrow">{{ activeSectionMeta.eyebrow }}</p>
<h3>{{ activeSectionMeta.title }}</h3>
</div>
<span v-if="activeSectionDirty" class="panel-tag warning-tag">有未保存修改</span>
<span v-else class="panel-tag">已同步</span>
</div>
<section v-if="activeSection === 'broadcast'" class="extension-broadcast-grid">
<form class="stack-form extension-form-card" @submit.prevent="saveActiveSection">
<div class="form-section-title">
<strong>{{ editingBroadcastId ? "编辑广播" : "新增广播" }}</strong>
<button class="ghost-button" type="button" @click="resetBroadcastForm">新建广播</button>
</div>
<div class="double-field">
<label class="form-field">
<span>广播标题 <i class="required-mark">*</i></span>
<input v-model.trim="broadcastForm.title" type="text" required />
</label>
<label class="form-field">
<span>提示类型</span>
<select v-model="broadcastForm.tone">
<option value="INFO">信息</option>
<option value="SUCCESS">成功</option>
<option value="WARNING">预警</option>
<option value="DANGER">重要</option>
</select>
</label>
</div>
<label class="form-field">
<span>广播内容 <i class="required-mark">*</i></span>
<input v-model.trim="broadcastForm.content" type="text" required />
</label>
<div class="form-field">
<span>通知考勤点 <i class="required-mark">*</i></span>
<div class="checkbox-grid extension-checkbox-grid">
<label v-for="point in attendancePoints" :key="point.name" class="checkbox-field">
<input v-model="broadcastForm.attendance_point_names" type="checkbox" :value="point.name" />
<span>{{ point.name }}</span>
</label>
</div>
</div>
<div class="triple-field">
<label class="form-field">
<span>开始时间</span>
<input v-model="broadcastForm.starts_at" type="datetime-local" />
</label>
<label class="form-field">
<span>结束时间</span>
<input v-model="broadcastForm.ends_at" type="datetime-local" />
</label>
<label class="form-field">
<span>排序号</span>
<input v-model.number="broadcastForm.sort_no" type="number" step="1" />
</label>
</div>
<div class="double-field">
<label class="form-field">
<span>状态</span>
<select v-model="broadcastForm.status">
<option value="ACTIVE">启用</option>
<option value="PAUSED">暂停</option>
</select>
</label>
<div class="extension-banner-preview">
<span>横幅预览</span>
<div class="broadcast-banner" :class="`broadcast-${broadcastForm.tone.toLowerCase()}`">
<span class="broadcast-speaker" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img">
<path d="M4 10.5H7.5L16 6.5V17.5L7.5 13.5H4V10.5Z" />
<path d="M7.5 13.5L9.2 19H12.2L10.4 14.8" />
<path d="M18.3 9.2L20.5 8" />
<path d="M18.7 12H21.4" />
<path d="M18.3 14.8L20.5 16" />
</svg>
</span>
<strong>{{ broadcastForm.title || "广播标题" }}</strong>
<span>{{ broadcastForm.content || "广播内容会显示在系统右上角" }}</span>
</div>
</div>
</div>
</form>
<section class="extension-list-card">
<div class="form-section-title">
<strong>广播记录</strong>
<span class="panel-tag">{{ filteredBroadcasts.length }} 条</span>
</div>
<TableControls
v-model:search="broadcastControls.search.value"
v-model:sort-key="broadcastControls.sortKey.value"
v-model:sort-direction="broadcastControls.sortDirection.value"
:sort-options="broadcastControls.sortOptions"
placeholder="搜索标题、内容、状态、类型"
/>
<div class="table-wrap extension-table-wrap">
<table class="data-table compact-table">
<thead>
<tr>
<th>标题</th>
<th>考勤点</th>
<th>内容</th>
<th>类型</th>
<th>状态</th>
<th>开始</th>
<th>结束</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in paginatedBroadcasts" :key="item.broadcast_id">
<td>{{ item.title }}</td>
<td>{{ formatAttendancePoints(item.attendance_point_names) }}</td>
<td>{{ item.content }}</td>
<td><span class="status-chip" :class="`status-${String(item.tone || 'INFO').toLowerCase()}`">{{ formatToneLabel(item.tone) }}</span></td>
<td><span class="status-chip" :class="item.status === 'ACTIVE' ? 'status-active' : 'status-paused'">{{ item.status === "ACTIVE" ? "启用" : "暂停" }}</span></td>
<td>{{ formatDateTime(item.starts_at) }}</td>
<td>{{ formatDateTime(item.ends_at) }}</td>
<td>{{ item.sort_no }}</td>
<td class="action-row">
<button class="ghost-button" type="button" @click="editBroadcast(item)">编辑</button>
</td>
</tr>
<tr v-if="!filteredBroadcasts.length">
<td colspan="9" class="empty-row">暂无广播内容</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar v-model:page="broadcastPage" v-model:page-size="broadcastPageSize" :total="filteredBroadcasts.length" />
</section>
</section>
<form v-else-if="activeSection === 'rawLotPrefix'" class="stack-form extension-form-card" @submit.prevent="saveActiveSection">
<div class="extension-setting-block">
<span class="setting-kicker">当前规则</span>
<strong>{{ rawLotPrefixForm.config_value || "YL" }}0001</strong>
<p>序号达到 9999 后自然扩展为 {{ rawLotPrefixForm.config_value || "YL" }}10000,不截断、不补新规则。</p>
</div>
<div class="double-field">
<label class="form-field">
<span>前缀</span>
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
</label>
<label class="form-field">
<span>说明</span>
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
</label>
</div>
</form>
<form v-else-if="activeSection === 'productionMode'" class="stack-form extension-form-card" @submit.prevent="saveActiveSection">
<label class="extension-mode-switch" :class="{ active: smartOperationReportForm.enabled }">
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
<span class="switch-track" aria-hidden="true"></span>
<span>
<strong>对接智能报工小程序</strong>
<small>{{ smartOperationReportForm.enabled ? "开启后,ERP 展示工序报工和小程序报工证据。" : "关闭后,ERP 隐藏工序报工,生产按入库闭环推进。" }}</small>
</span>
</label>
<div class="extension-impact-list">
<article>
<span>开启时</span>
<strong>显示工序报工</strong>
<p>工单明细展示小程序报工证据,生产台账可参考工序进度。</p>
</article>
<article>
<span>关闭时</span>
<strong>ERP 入库闭环</strong>
<p>隐藏工序报工入口,成品、余料、废料按 ERP 单据推进。</p>
</article>
</div>
<label class="form-field">
<span>说明</span>
<input v-model.trim="smartOperationReportForm.remark" type="text" placeholder="例如:客户未购买小程序模块,暂按ERP入库反推" />
</label>
</form>
<form v-else class="stack-form extension-form-card" @submit.prevent="saveActiveSection">
<div class="triple-field">
<label class="form-field">
<span>助手名称</span>
<input v-model.trim="assistantForm.assistant_name" type="text" required />
</label>
<label class="form-field">
<span>供应商</span>
<input v-model.trim="assistantForm.provider_name" type="text" placeholder="例如:OpenRouter / SiliconFlow" />
</label>
<label class="form-field">
<span>模型名称</span>
<input v-model.trim="assistantForm.model_name" type="text" placeholder="例如:deepseek-chat" />
</label>
</div>
<div class="double-field">
<label class="form-field">
<span>API Base URL</span>
<input v-model.trim="assistantForm.api_base_url" type="text" placeholder="https://api.example.com/v1" />
</label>
<label class="form-field">
<span>API Key</span>
<input v-model.trim="assistantForm.api_key" type="password" :placeholder="assistantConfig.api_key_configured ? '留空表示不修改现有密钥' : '请输入 API Key'" />
</label>
</div>
<div class="double-field">
<label class="form-field">
<span>温度</span>
<input v-model.number="assistantForm.temperature" type="number" min="0" max="2" step="0.1" />
</label>
<label class="extension-mode-switch" :class="{ active: assistantForm.enabled }">
<input v-model="assistantForm.enabled" type="checkbox" />
<span class="switch-track" aria-hidden="true"></span>
<span>
<strong>启用真实 LLM 调用</strong>
<small>{{ assistantConfig.api_key_configured ? "密钥已配置,保存后按当前开关生效。" : "未配置密钥时建议保持占位模式。" }}</small>
</span>
</label>
</div>
<label class="form-field">
<span>每次新建对话的系统提示词</span>
<textarea v-model.trim="assistantForm.system_prompt" rows="6"></textarea>
</label>
</form>
</main>
</section>
<div v-if="activeSectionDirty" class="extension-save-dock">
<span>{{ activeSectionMeta.title }}有未保存修改</span>
<button class="ghost-button" type="button" @click="resetActiveSection">放弃修改</button>
<button class="primary-button" type="button" @click="saveActiveSection">保存配置</button>
</div>
</section>
</template>
- Step 2: Run the source test
Run:
cd frontend
node --test src/views/SystemExtensionView.test.js
Expected:
not ok ... renders a compact system extension workbench shell
The old hero assertions should now pass. CSS assertions still fail until Task 4.
Task 4: Add Workbench CSS and Remove Old Extension Overrides
Files:
-
Modify:
frontend/src/styles/main.css -
Step 1: Remove old extension hero selectors
Delete these old blocks if they still exist:
.extension-hero {
align-items: center;
}
.extension-status-card {
display: grid;
gap: 8px;
align-content: center;
padding: 20px;
border-radius: 24px;
border: 1px solid rgba(126, 198, 255, 0.24);
background: rgba(255, 255, 255, 0.06);
}
.extension-status-card strong {
color: #f5fbff;
font-size: 18px;
}
.compact-extension-form {
max-width: 1180px;
}
.extension-preview-field {
justify-content: end;
}
.extension-preview-banner {
max-width: 100%;
}
Also remove .extension-status-card from grouped old card override selectors where it appears with .kpi-card, .hero-stat, .summary-card.
- Step 2: Append new workbench styles near other system/page styles
Add:
.system-extension-workbench {
display: grid;
gap: 16px;
padding-bottom: 92px;
}
.extension-workbench-head {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.8fr);
gap: 16px;
align-items: end;
}
.extension-workbench-head h2 {
margin: 2px 0 0;
color: var(--text-strong);
font-size: clamp(24px, 2.2vw, 34px);
letter-spacing: -0.04em;
}
.extension-status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.extension-status-pill {
min-width: 0;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(245, 248, 252, 0.9)),
radial-gradient(circle at 12% 0%, rgba(56, 189, 248, 0.12), transparent 34%);
box-shadow: var(--card-shadow-low), var(--card-highlight);
}
.extension-status-pill span {
display: block;
color: var(--text-muted);
font-size: 12px;
}
.extension-status-pill strong {
display: block;
margin-top: 5px;
overflow: hidden;
color: var(--text-strong);
font-size: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-status-success {
border-color: rgba(34, 197, 94, 0.28);
}
.extension-status-warning {
border-color: rgba(245, 158, 11, 0.34);
}
.extension-status-info {
border-color: rgba(14, 165, 233, 0.28);
}
.extension-status-muted {
border-color: rgba(148, 163, 184, 0.26);
}
.extension-config-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.extension-nav-rail {
position: sticky;
top: 88px;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 24px;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.96), rgba(239, 245, 251, 0.9));
box-shadow: var(--card-shadow-low), var(--card-highlight);
}
.extension-nav-button {
position: relative;
display: grid;
gap: 4px;
width: 100%;
padding: 14px 14px 14px 18px;
overflow: hidden;
border: 1px solid transparent;
border-radius: 18px;
background: transparent;
color: var(--text-muted);
text-align: left;
cursor: pointer;
transition:
transform 0.18s ease,
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
}
.extension-nav-button::before {
content: "";
position: absolute;
inset: 12px auto 12px 8px;
width: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.38);
}
.extension-nav-button span {
color: var(--text-subtle);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.extension-nav-button strong {
color: var(--text-strong);
font-size: 15px;
}
.extension-nav-button small {
color: var(--text-muted);
line-height: 1.45;
}
.extension-nav-button:hover,
.extension-nav-button.active {
border-color: rgba(31, 78, 121, 0.2);
background: #ffffff;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.extension-nav-blue.active::before {
background: #0ea5e9;
}
.extension-nav-steel.active::before {
background: #475569;
}
.extension-nav-green.active::before {
background: #16a34a;
}
.extension-nav-orange.active::before {
background: #f97316;
}
.extension-module-panel {
min-width: 0;
padding: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 253, 0.94)),
radial-gradient(circle at 96% 0%, rgba(59, 130, 246, 0.1), transparent 30%);
box-shadow: var(--card-shadow), var(--card-highlight);
}
.extension-module-head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.extension-module-head h3 {
margin: 2px 0 0;
color: var(--text-strong);
font-size: 22px;
}
.warning-tag {
border-color: rgba(245, 158, 11, 0.28);
background: rgba(255, 247, 237, 0.92);
color: #b45309;
}
.extension-broadcast-grid {
display: grid;
grid-template-columns: minmax(360px, 0.86fr) minmax(0, 1.14fr);
gap: 16px;
align-items: start;
}
.extension-form-card,
.extension-list-card {
min-width: 0;
padding: 16px;
border: 1px solid rgba(203, 213, 225, 0.72);
border-radius: 22px;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
}
.form-section-title {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.form-section-title strong {
color: var(--text-strong);
font-size: 16px;
}
.extension-checkbox-grid {
max-height: 178px;
overflow: auto;
padding-right: 4px;
}
.extension-banner-preview {
display: grid;
gap: 8px;
align-content: end;
}
.extension-banner-preview > span {
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
}
.extension-table-wrap {
max-height: 430px;
}
.extension-setting-block {
display: grid;
gap: 6px;
padding: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 22px;
background:
linear-gradient(135deg, #0f172a, #1e3a5f),
radial-gradient(circle at 16% 0%, rgba(125, 211, 252, 0.26), transparent 34%);
color: #e5eef8;
}
.extension-setting-block .setting-kicker {
color: rgba(226, 232, 240, 0.72);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.extension-setting-block strong {
font-size: clamp(28px, 4vw, 48px);
letter-spacing: 0.04em;
}
.extension-setting-block p {
margin: 0;
color: rgba(226, 232, 240, 0.78);
}
.extension-mode-switch {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
align-items: center;
padding: 16px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 22px;
background: #ffffff;
cursor: pointer;
}
.extension-mode-switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.extension-mode-switch .switch-track {
position: relative;
width: 54px;
height: 30px;
border-radius: 999px;
background: #cbd5e1;
box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.16);
transition: background 0.18s ease;
}
.extension-mode-switch .switch-track::after {
content: "";
position: absolute;
top: 4px;
left: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.24);
transition: transform 0.18s ease;
}
.extension-mode-switch.active .switch-track {
background: #0f766e;
}
.extension-mode-switch.active .switch-track::after {
transform: translateX(24px);
}
.extension-mode-switch strong {
display: block;
color: var(--text-strong);
}
.extension-mode-switch small {
display: block;
margin-top: 4px;
color: var(--text-muted);
line-height: 1.45;
}
.extension-impact-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.extension-impact-list article {
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.76);
border-radius: 18px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
}
.extension-impact-list span {
color: var(--text-subtle);
font-size: 12px;
font-weight: 800;
}
.extension-impact-list strong {
display: block;
margin-top: 4px;
color: var(--text-strong);
}
.extension-impact-list p {
margin: 6px 0 0;
color: var(--text-muted);
line-height: 1.5;
}
.extension-save-dock {
position: fixed;
right: 28px;
bottom: 28px;
z-index: 30;
display: flex;
gap: 10px;
align-items: center;
max-width: min(680px, calc(100vw - 56px));
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(18px);
}
.extension-save-dock span {
padding: 0 8px;
color: var(--text-muted);
font-weight: 700;
white-space: nowrap;
}
.status-chip.status-info {
background: rgba(14, 165, 233, 0.12);
color: #0369a1;
}
.status-chip.status-success {
background: rgba(34, 197, 94, 0.14);
color: #15803d;
}
.status-chip.status-warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.status-chip.status-danger {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.status-chip.status-active {
background: rgba(34, 197, 94, 0.14);
color: #15803d;
}
.status-chip.status-paused {
background: rgba(148, 163, 184, 0.16);
color: #475569;
}
@media (max-width: 1180px) {
.extension-workbench-head {
grid-template-columns: 1fr;
}
.extension-config-shell {
grid-template-columns: 1fr;
}
.extension-nav-rail {
position: static;
grid-template-columns: repeat(4, minmax(180px, 1fr));
overflow-x: auto;
}
.extension-broadcast-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.extension-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.extension-impact-list {
grid-template-columns: 1fr;
}
.extension-save-dock {
right: 14px;
bottom: 14px;
left: 14px;
justify-content: flex-end;
border-radius: 24px;
}
.extension-save-dock span {
display: none;
}
}
- Step 3: Run source test
Run:
cd frontend
node --test src/views/SystemExtensionView.test.js
Expected:
ok ... SystemExtensionView workbench redesign
Task 5: Verify Build and Browser Behavior
Files:
-
No additional files unless the build exposes a syntax issue.
-
Step 1: Run targeted test
Run:
cd frontend
node --test src/views/SystemExtensionView.test.js
Expected:
# pass 5
# fail 0
- Step 2: Run frontend build
Run:
cd frontend
npm run build
Expected:
✓ built in
The existing Vite chunk-size warning is acceptable if it appears; do not treat it as a failure.
- Step 3: Browser smoke test
Use the in-app Browser on:
http://localhost:5173/system-extension
Verify:
顶部只有紧凑标题和四个状态 pill
没有 ADMIN ONLY
没有大面积 hero 解释卡
左侧目录有 广播通知 / 库存批次规则 / 生产对接模式 / AI 助手配置
点击每个目录只切换右侧模块,不刷新整页
修改任意字段后右下角出现 放弃修改 / 保存配置
点击放弃修改后字段回到已加载状态,保存栏消失
广播通知列表状态有颜色区分
窄屏下左侧目录变横向滚动,不挤压右侧表单
- Step 4: Git safety check
Run:
git status --short
Expected changed files for this plan:
M frontend/src/views/SystemExtensionView.vue
M frontend/src/styles/main.css
?? frontend/src/views/SystemExtensionView.test.js
Existing DAG changes may still appear. Do not revert them. Do not commit unless the user explicitly asks.
Self-Review Checklist
- Spec coverage: Removes old large explanation UI, creates compact workbench, preserves all current fields and endpoints, adds status color distinction, adds fixed save/reset dock, supports responsive layout.
- Placeholder scan: No implementation step contains placeholder wording or含糊处理描述。
- Type consistency: Uses one key set everywhere:
broadcast,rawLotPrefix,productionMode,assistant. - API safety: Existing endpoints remain unchanged.
- Dirty worktree safety: Plan explicitly avoids committing and warns not to touch unrelated DAG files.
Execution Options
Plan complete and saved to docs/superpowers/plans/2026-06-14-system-extension-workbench.md.
1. Subagent-Driven (recommended) - Dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints.