1411 lines
38 KiB
Markdown
1411 lines
38 KiB
Markdown
# 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.
|