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

38 KiB
Raw Permalink Blame History

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.pyfrontend/src/views/DashboardView.vuefrontend/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.