ForgeFlow-ERP/docs/superpowers/plans/2026-06-14-system-extension-workbench.md

1411 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
页面最终结构:
```text
系统拓展
广播启用数 | 库存批次前缀 | 报工小程序状态 | 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:
```js
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:
```bash
cd frontend
node --test src/views/SystemExtensionView.test.js
```
Expected:
```text
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:
```js
import { onMounted, reactive, ref } from "vue";
```
To:
```js
import { computed, onMounted, reactive, ref } from "vue";
```
- [ ] **Step 2: Add navigation state after existing refs**
Add after:
```js
const editingBroadcastId = ref(null);
```
This block:
```js
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:
```js
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:
```js
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:
```js
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:
```js
broadcasts.value = await fetchResource("/system-extension/broadcasts", []);
```
Add:
```js
captureSectionSnapshot("broadcast");
```
At the end of successful `saveAssistantConfig`, after feedback:
```js
feedbackMessage.value = "AI 机器人配置已保存";
```
Add:
```js
captureSectionSnapshot("assistant");
```
At the end of successful `saveRawLotPrefixConfig`, after feedback:
```js
feedbackMessage.value = "原材料库存批次号前缀已保存";
```
Add:
```js
captureSectionSnapshot("rawLotPrefix");
```
At the end of successful `saveSmartOperationReportConfig`, after feedback:
```js
feedbackMessage.value = "生产模式配置已保存";
```
Add:
```js
captureSectionSnapshot("productionMode");
```
- [ ] **Step 7: Capture initial snapshots after loading**
Change `onMounted` from:
```js
onMounted(async () => {
await loadAll();
});
```
To:
```js
onMounted(async () => {
await loadAll();
captureAllSectionSnapshots();
});
```
- [ ] **Step 8: Run the source test**
Run:
```bash
cd frontend
node --test src/views/SystemExtensionView.test.js
```
Expected:
```text
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:
```vue
<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:
```bash
cd frontend
node --test src/views/SystemExtensionView.test.js
```
Expected:
```text
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:
```css
.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:
```css
.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:
```bash
cd frontend
node --test src/views/SystemExtensionView.test.js
```
Expected:
```text
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:
```bash
cd frontend
node --test src/views/SystemExtensionView.test.js
```
Expected:
```text
# pass 5
# fail 0
```
- [ ] **Step 2: Run frontend build**
Run:
```bash
cd frontend
npm run build
```
Expected:
```text
✓ 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:
```text
http://localhost:5173/system-extension
```
Verify:
```text
顶部只有紧凑标题和四个状态 pill
没有 ADMIN ONLY
没有大面积 hero 解释卡
左侧目录有 广播通知 / 库存批次规则 / 生产对接模式 / AI 助手配置
点击每个目录只切换右侧模块,不刷新整页
修改任意字段后右下角出现 放弃修改 / 保存配置
点击放弃修改后字段回到已加载状态,保存栏消失
广播通知列表状态有颜色区分
窄屏下左侧目录变横向滚动,不挤压右侧表单
```
- [ ] **Step 4: Git safety check**
Run:
```bash
git status --short
```
Expected changed files for this plan:
```text
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.