ForgeFlow-ERP/docs/superpowers/plans/2026-06-09-erp-frontend-visual-upgrade.md
2026-06-12 16:00:56 +08:00

2652 lines
67 KiB
Markdown
Raw Permalink 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.

# ERP Frontend Visual Upgrade 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:** Upgrade the ERP frontend from a demo-like interface to a polished, production-grade industrial operations UI without changing business fields, backend APIs, or core workflows.
**Architecture:** Build a global visual system first, then migrate high-frequency pages onto it. The upgrade is CSS-first and component-first: shared tokens, shell, icon, button, status, table, drawer, select, and feedback primitives will be improved before page-specific polish. Business logic remains in existing views; new components only change presentation and interaction ergonomics.
**Tech Stack:** Vue 3, Vite 5, Vue Router, CSS variables in `frontend/src/styles/main.css`, existing shared Vue components under `frontend/src/components`, existing utility modules under `frontend/src/utils`.
---
## Design Skill Map
- **frontend-design:** Use the visual direction `工业精密操作台`. DFII score: 13. Aesthetic impact 4, context fit 5, feasibility 5, performance safety 4, consistency risk 5. The recognizable anchor is a deep industrial navigation rail plus bright, precise data surfaces with strong status chips.
- **tailwind-patterns:** Use Tailwind-style token architecture without adding Tailwind as a dependency. Tokens live as CSS variables: semantic colors, spacing, radius, shadow, z-index, density, motion.
- **iconsax-library:** Use a single Iconsax-inspired two-tone/linear icon language. Do not mix random emoji, text symbols, and unrelated icon weights.
- **design-spells:** Add restrained micro-interactions: drawer entrance, table row hover rail, status pulse for warning/danger, button press feedback, scroll shadows, focus rings.
- **magic-ui-generator:** Use the skill as a design-variation discipline. Before implementing each major primitive, compare 2-3 variants in comments/screenshots and implement the selected variant only. For this ERP, the selected variant should be "industrial precision", not purple SaaS, dark-only, or generic admin.
## Non-Negotiable Scope Boundary
- Do not remove existing form fields.
- Do not rename business fields unless the user separately requests it.
- Do not alter backend payloads, validation rules, or workflow sequencing.
- Do not add a UI library dependency.
- Do not rewrite large business views purely for aesthetics.
- Do not leave English status values visible to users.
- Do not rely on permanent gray explanation blocks when a tooltip, popover, or compact hint can carry the same information.
## File Structure
**Create:**
- `frontend/src/components/AppIcon.vue` - shared inline SVG icon renderer using a single Iconsax-inspired style.
- `frontend/src/components/StatusBadge.vue` - shared status badge for tables, cards, select labels, and summaries.
- `frontend/src/components/SmartDrawer.vue` - enhanced drawer shell for complex business forms.
- `frontend/src/components/FormSection.vue` - consistent form section card with optional compact help.
- `frontend/src/components/FieldHint.vue` - tooltip/popover style helper text component.
- `frontend/src/components/ActionButton.vue` - consistent button primitive with variants.
- `frontend/src/components/MetricStrip.vue` - compact summary metrics used in drawers and page headers.
- `frontend/src/utils/statusToneMap.js` - Chinese-first status label and tone dictionary.
- `frontend/src/utils/iconMap.js` - stable icon names mapped to SVG path data.
- `frontend/src/utils/uiAudit.js` - development-only helpers for checking demo-like UI smells.
**Modify:**
- `frontend/src/styles/main.css` - tokens, shell, tables, drawers, form, badges, buttons, motion, responsive rules.
- `frontend/src/main.js` - register or preserve global status enhancer depending on migration stage.
- `frontend/src/App.vue` - app shell, sidebar, top bar, navigation icons, active state, responsive behavior.
- `frontend/src/components/TableControls.vue` - search/filter bar and sort icon behavior.
- `frontend/src/components/DrawerDataTable.vue` - readable drawer table layout.
- `frontend/src/components/GlobalSearchableSelect.vue` - better long-option select panel styling and metadata layout.
- `frontend/src/components/StockLotTagSelect.vue` - match global select/tag behavior.
- `frontend/src/components/StocktakeDialog.vue` - align stocktake modal with the new drawer/table/status system.
- `frontend/src/components/OrgMindMap.vue` - match system-management visual style, controls, and canvas polish.
- `frontend/src/components/OrgPermissionTree.vue` - align tree/table with new status and action components.
- `frontend/src/views/DashboardView.vue` - dashboard table and header polish.
- `frontend/src/views/InventoryLedgerView.vue` - warehouse operation zone, inventory table, and operation drawers.
- `frontend/src/views/PurchaseOrderView.vue` - procurement list and create/edit drawer.
- `frontend/src/views/PurchaseReceiptView.vue` - receipt list and drawer.
- `frontend/src/views/QualityInspectionView.vue` - pending/completed status visibility.
- `frontend/src/views/SalesPlanningView.vue` - customer/order tables, drawer forms, contract action area.
- `frontend/src/views/ProductionLedgerView.vue` - production ledger table and detail drawer.
- `frontend/src/views/OperationReportView.vue` - date filter, table, empty state, status.
- `frontend/src/views/DeliveryManagementView.vue` - grouped delivery ledger table and detail drawer.
- `frontend/src/views/FinanceReportView.vue` - large financial spreadsheet mode.
- `frontend/src/views/SystemPermissionView.vue` - tab layout, org mode controls, role tables.
**Test/Verify:**
- `npm run build`
- Browser inspection at `http://127.0.0.1:5173/`
- Browser inspection at desktop `1440x900`
- Browser inspection at narrow `390x844`
- Browser screenshots for `/`, `/inventory-ledger`, `/purchase-orders`, `/order-list`, `/production-ledger`, `/delivery-management`, `/finance-reports`, `/system-permissions`
---
### Task 1: Baseline UI Audit And Design Tokens
**Files:**
- Modify: `frontend/src/styles/main.css`
- Create: `frontend/src/utils/uiAudit.js`
- Verify: browser screenshots and `npm run build`
- [ ] **Step 1: Capture current baseline screenshots**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
vite v5.x building for production...
✓ built
```
Use the browser plugin to capture these pages at `1440x900` and `390x844`:
```text
http://127.0.0.1:5173/
http://127.0.0.1:5173/inventory-ledger
http://127.0.0.1:5173/purchase-orders
http://127.0.0.1:5173/order-list
http://127.0.0.1:5173/finance-reports
http://127.0.0.1:5173/system-permissions
```
Expected findings to record in the execution notes:
```text
sidebar squeezes narrow viewport
table sort arrows show as raw text
status display is inconsistent
drawers feel crowded
long select options are hard to scan
permanent helper text takes too much space
```
- [ ] **Step 2: Add development UI audit helper**
Create `frontend/src/utils/uiAudit.js`:
```js
export function collectUiAuditSnapshot(root = document) {
const normalize = (value) => String(value || "").trim().replace(/\s+/g, " ");
const tables = Array.from(root.querySelectorAll("table")).map((table, index) => {
const rect = table.getBoundingClientRect();
const wrap = table.closest(".table-wrap, .drawer-data-table-wrap, .finance-sheet-wrap") || table.parentElement;
const wrapRect = wrap?.getBoundingClientRect();
return {
index,
width: Math.round(rect.width),
wrapWidth: Math.round(wrapRect?.width || rect.width),
hasRawSortText: Array.from(table.querySelectorAll("th")).some((th) => normalize(th.textContent).includes("↕")),
headerCount: table.querySelectorAll("th").length
};
});
const visibleEnglishStatuses = Array.from(root.querySelectorAll("td, option, span, button"))
.map((element) => normalize(element.textContent))
.filter((text) => /\b(ACTIVE|INACTIVE|PENDING|LOCKED|OPEN|CLOSED|DONE|PASS|REJECT|SETTLED)\b/.test(text));
const longHelperBlocks = Array.from(root.querySelectorAll("small, .workflow-tip, .feedback-banner"))
.map((element) => normalize(element.textContent))
.filter((text) => text.length > 48);
return {
path: window.location.pathname,
viewport: { width: window.innerWidth, height: window.innerHeight },
tables,
visibleEnglishStatuses,
longHelperBlocks
};
}
export function printUiAuditSnapshot(root = document) {
const snapshot = collectUiAuditSnapshot(root);
console.table(snapshot.tables);
console.log("visibleEnglishStatuses", snapshot.visibleEnglishStatuses);
console.log("longHelperBlocks", snapshot.longHelperBlocks);
return snapshot;
}
```
- [ ] **Step 3: Add semantic design tokens**
Append a new token section near the top of `frontend/src/styles/main.css`, after the existing `:root` block. Keep old variables during migration so existing pages do not break.
```css
:root {
--ui-font-display: "Bahnschrift", "DIN Alternate", "Segoe UI", sans-serif;
--ui-font-body: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
--ui-bg-canvas: #eef3f7;
--ui-bg-panel: rgba(255, 255, 255, 0.92);
--ui-bg-panel-solid: #ffffff;
--ui-bg-subtle: #f6f8fb;
--ui-ink: #15202b;
--ui-ink-soft: #4f6275;
--ui-ink-muted: #7b8da0;
--ui-line: rgba(58, 82, 105, 0.14);
--ui-line-strong: rgba(58, 82, 105, 0.24);
--ui-steel: #2563eb;
--ui-steel-soft: #dbeafe;
--ui-cyan: #0ea5e9;
--ui-green: #16a34a;
--ui-amber: #d97706;
--ui-orange: #ea580c;
--ui-red: #dc2626;
--ui-gray: #64748b;
--ui-radius-sm: 8px;
--ui-radius-md: 12px;
--ui-radius-lg: 18px;
--ui-radius-xl: 24px;
--ui-shadow-card: 0 16px 42px rgba(15, 23, 42, 0.08);
--ui-shadow-float: 0 28px 80px rgba(15, 23, 42, 0.18);
--ui-focus: 0 0 0 3px rgba(37, 99, 235, 0.18);
--ui-transition-fast: 140ms ease;
--ui-transition-normal: 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
--ui-sidebar-width: 220px;
--ui-sidebar-collapsed-width: 76px;
}
```
- [ ] **Step 4: Add base application surface styles**
Append to `frontend/src/styles/main.css`:
```css
body {
font-family: var(--ui-font-body);
color: var(--ui-ink);
background:
radial-gradient(circle at 16% 8%, rgba(37, 99, 235, 0.12), transparent 28%),
linear-gradient(135deg, #eef3f7 0%, #f8fafc 48%, #e8eef5 100%);
}
button,
input,
select,
textarea {
font: inherit;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a:focus-visible {
outline: none;
box-shadow: var(--ui-focus);
}
```
- [ ] **Step 5: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser check:
```text
Open http://127.0.0.1:5173/
Confirm no layout regression from token addition.
```
- [ ] **Step 6: Commit**
If the executor is inside a git worktree:
```bash
git add frontend/src/styles/main.css frontend/src/utils/uiAudit.js
git commit -m "style: add frontend visual design tokens"
```
If the current directory has no `.git`, record this in the execution notes:
```text
Commit skipped because current workspace is not a git repository.
```
---
### Task 2: Iconsax-Inspired Icon System
**Files:**
- Create: `frontend/src/utils/iconMap.js`
- Create: `frontend/src/components/AppIcon.vue`
- Modify: `frontend/src/App.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Create icon map**
Create `frontend/src/utils/iconMap.js`:
```js
export const ICON_PATHS = {
dashboard: [
"M4 13h6v7H4z",
"M14 4h6v16h-6z",
"M4 4h6v5H4z"
],
masterData: [
"M5 5h14v4H5z",
"M5 15h14v4H5z",
"M8 9v6",
"M16 9v6"
],
order: [
"M7 3h8l4 4v14H7z",
"M15 3v5h5",
"M9 12h8",
"M9 16h6"
],
warehouse: [
"M4 9l8-5 8 5-8 5z",
"M4 9v7l8 5 8-5V9",
"M12 14v7"
],
production: [
"M12 3v3",
"M12 18v3",
"M4.8 7.2l2.1 2.1",
"M17.1 16.7l2.1 2.1",
"M3 12h3",
"M18 12h3",
"M7.2 19.2l2.1-2.1",
"M16.7 6.9l2.1-2.1",
"M8 12a4 4 0 1 0 8 0 4 4 0 0 0-8 0z"
],
delivery: [
"M3 7h11v10H3z",
"M14 10h4l3 3v4h-7z",
"M6.5 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z",
"M17.5 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"
],
finance: [
"M4 19V5",
"M4 19h16",
"M8 15l3-4 3 2 5-7"
],
system: [
"M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
"M19.4 15a8 8 0 0 0 .1-5.8l-2.2.4a6.4 6.4 0 0 0-1.1-1.9l1.2-1.9a8 8 0 0 0-5.1-2.9l-.8 2.1a6.8 6.8 0 0 0-2.2 0l-.8-2.1a8 8 0 0 0-5.1 2.9l1.2 1.9a6.4 6.4 0 0 0-1.1 1.9l-2.2-.4a8 8 0 0 0 .1 5.8l2.1-.6a6.6 6.6 0 0 0 1.2 1.7l-1 2a8 8 0 0 0 5.1 2.8l.7-2.1a6.7 6.7 0 0 0 2.4 0l.7 2.1a8 8 0 0 0 5.1-2.8l-1-2a6.6 6.6 0 0 0 1.2-1.7z"
],
plus: ["M12 5v14", "M5 12h14"],
minus: ["M5 12h14"],
refresh: ["M20 6v5h-5", "M4 18v-5h5", "M18.5 9A7 7 0 0 0 6.8 6.8L4 11", "M5.5 15a7 7 0 0 0 11.7 2.2L20 13"],
ledger: ["M6 4h12v16H6z", "M9 8h6", "M9 12h6", "M9 16h4"],
stocktake: ["M5 5h14v14H5z", "M8 9h8", "M8 13h5", "M8 17h3"],
close: ["M6 6l12 12", "M18 6L6 18"],
search: ["M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z", "M16 16l5 5"],
warning: ["M12 4l9 16H3z", "M12 9v5", "M12 17h.01"]
};
```
- [ ] **Step 2: Create shared icon component**
Create `frontend/src/components/AppIcon.vue`:
```vue
<template>
<svg class="app-icon" :class="[`app-icon-${tone}`]" viewBox="0 0 24 24" aria-hidden="true">
<path
v-for="path in paths"
:key="path"
:d="path"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="strokeWidth"
/>
</svg>
</template>
<script setup>
import { computed } from "vue";
import { ICON_PATHS } from "../utils/iconMap";
const props = defineProps({
name: {
type: String,
required: true
},
tone: {
type: String,
default: "default"
},
strokeWidth: {
type: [Number, String],
default: 1.8
}
});
const paths = computed(() => ICON_PATHS[props.name] || ICON_PATHS.dashboard);
</script>
```
- [ ] **Step 3: Add icon styles**
Append to `frontend/src/styles/main.css`:
```css
.app-icon {
width: 20px;
height: 20px;
flex: 0 0 auto;
color: currentColor;
}
.app-icon-muted {
color: var(--ui-ink-muted);
}
.app-icon-primary {
color: var(--ui-steel);
}
.app-icon-success {
color: var(--ui-green);
}
.app-icon-warning {
color: var(--ui-amber);
}
.app-icon-danger {
color: var(--ui-red);
}
```
- [ ] **Step 4: Replace sidebar raw icon output in `App.vue`**
In `frontend/src/App.vue`, import the component:
```js
import AppIcon from "./components/AppIcon.vue";
```
Update the navigation item data to carry icon names:
```js
const navStages = [
{ key: "dashboard", icon: "dashboard", label: "经营总览" },
{ key: "master-data", icon: "masterData", label: "基础资料" },
{ key: "orders", icon: "order", label: "订单与计划" },
{ key: "procurement", icon: "warehouse", label: "采购与库存" },
{ key: "production", icon: "production", label: "生产执行" },
{ key: "delivery", icon: "delivery", label: "发货概览" },
{ key: "finance", icon: "finance", label: "财务分析" },
{ key: "system", icon: "system", label: "系统管理" }
];
```
Where nav buttons render their icon, replace text/emoji icon output with:
```vue
<AppIcon :name="stage.icon" />
```
- [ ] **Step 5: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser check:
```text
Open http://127.0.0.1:5173/
Confirm all sidebar main menu icons render with one consistent line style.
Confirm no duplicate-looking menu icons remain in the main sidebar.
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/utils/iconMap.js frontend/src/components/AppIcon.vue frontend/src/App.vue frontend/src/styles/main.css
git commit -m "style: unify app icon system"
```
If no `.git` directory exists, record:
```text
Commit skipped because current workspace is not a git repository.
```
---
### Task 3: Production-Grade App Shell And Responsive Sidebar
**Files:**
- Modify: `frontend/src/App.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add app shell state**
In `frontend/src/App.vue`, ensure the shell has a collapsed state that can be toggled and also activated by narrow viewport.
Use this script shape:
```js
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
const sidebarCollapsed = ref(false);
const narrowShell = ref(false);
function updateShellViewportState() {
narrowShell.value = window.innerWidth < 1024;
if (narrowShell.value) {
sidebarCollapsed.value = true;
}
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
onMounted(() => {
updateShellViewportState();
window.addEventListener("resize", updateShellViewportState);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateShellViewportState);
});
const shellClass = computed(() => ({
"app-shell-collapsed": sidebarCollapsed.value,
"app-shell-narrow": narrowShell.value
}));
```
Apply `:class="shellClass"` to the outer authenticated shell container.
- [ ] **Step 2: Replace permanent long nav descriptions**
Keep the existing `title` attributes for accessibility, but do not render long descriptions as visible button text. The button visible text must be only the module name:
```vue
<button class="nav-stage" :title="stage.description" type="button">
<AppIcon :name="stage.icon" />
<span class="nav-stage-label">{{ stage.label }}</span>
<span v-if="stage.children?.length" class="nav-stage-caret"></span>
</button>
```
- [ ] **Step 3: Add shell CSS**
Append to `frontend/src/styles/main.css`:
```css
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: var(--ui-sidebar-width) minmax(0, 1fr);
background: var(--ui-bg-canvas);
}
.app-shell.app-shell-collapsed {
grid-template-columns: var(--ui-sidebar-collapsed-width) minmax(0, 1fr);
}
.app-sidebar {
width: var(--ui-sidebar-width);
min-width: 0;
background:
linear-gradient(180deg, rgba(22, 34, 49, 0.96), rgba(17, 30, 45, 0.98)),
radial-gradient(circle at 20% 0%, rgba(37, 99, 235, 0.25), transparent 32%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 18px 0 50px rgba(15, 23, 42, 0.16);
}
.app-shell-collapsed .app-sidebar {
width: var(--ui-sidebar-collapsed-width);
}
.nav-stage {
position: relative;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) 16px;
align-items: center;
gap: 12px;
min-height: 46px;
padding: 0 14px;
border-radius: 14px;
color: rgba(226, 237, 248, 0.78);
transition: background var(--ui-transition-fast), color var(--ui-transition-fast), transform var(--ui-transition-fast);
}
.nav-stage:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
transform: translateX(2px);
}
.nav-stage-active {
color: #fff;
background: linear-gradient(90deg, rgba(37, 99, 235, 0.34), rgba(14, 165, 233, 0.12));
box-shadow: inset 3px 0 0 var(--ui-cyan);
}
.app-shell-collapsed .nav-stage {
grid-template-columns: 24px;
justify-content: center;
padding: 0;
}
.app-shell-collapsed .nav-stage-label,
.app-shell-collapsed .nav-stage-caret {
display: none;
}
.app-main {
min-width: 0;
overflow: hidden;
}
@media (max-width: 768px) {
.app-shell {
grid-template-columns: var(--ui-sidebar-collapsed-width) minmax(0, 1fr);
}
.app-main {
min-width: 0;
}
}
```
If existing classes use different names, map these rules to the actual shell classes in `App.vue` rather than duplicating wrappers.
- [ ] **Step 4: Verify narrow viewport**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
At 1440x900, sidebar shows labels and content has normal width.
At 390x844, sidebar collapses to icons and content is not squeezed into a narrow strip.
Top bar remains usable.
```
- [ ] **Step 5: Commit**
```bash
git add frontend/src/App.vue frontend/src/styles/main.css
git commit -m "style: polish app shell and responsive sidebar"
```
---
### Task 4: Button, Action, And Helper Text System
**Files:**
- Create: `frontend/src/components/ActionButton.vue`
- Create: `frontend/src/components/FieldHint.vue`
- Modify: `frontend/src/styles/main.css`
- Modify: `frontend/src/components/FormDrawer.vue`
- Modify: selected high-frequency views when migrating buttons
- [ ] **Step 1: Create shared action button**
Create `frontend/src/components/ActionButton.vue`:
```vue
<template>
<button
class="action-button"
:class="[`action-button-${variant}`, `action-button-${size}`, { 'action-button-icon-only': iconOnly }]"
:type="type"
:disabled="disabled || loading"
>
<AppIcon v-if="icon" :name="icon" :tone="iconTone" />
<span v-if="!iconOnly"><slot /></span>
<span v-if="loading" class="action-button-spinner" aria-hidden="true"></span>
</button>
</template>
<script setup>
import { computed } from "vue";
import AppIcon from "./AppIcon.vue";
const props = defineProps({
icon: { type: String, default: "" },
variant: { type: String, default: "secondary" },
size: { type: String, default: "md" },
type: { type: String, default: "button" },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
iconOnly: { type: Boolean, default: false }
});
const iconTone = computed(() => {
if (props.variant === "danger") return "danger";
if (props.variant === "primary") return "primary";
if (props.variant === "success") return "success";
if (props.variant === "warning") return "warning";
return "muted";
});
</script>
```
- [ ] **Step 2: Create field hint component**
Create `frontend/src/components/FieldHint.vue`:
```vue
<template>
<span class="field-hint" tabindex="0">
<AppIcon name="warning" stroke-width="1.6" />
<span class="field-hint-popover">{{ text }}</span>
</span>
</template>
<script setup>
import AppIcon from "./AppIcon.vue";
defineProps({
text: {
type: String,
required: true
}
});
</script>
```
- [ ] **Step 3: Add button and helper styles**
Append to `frontend/src/styles/main.css`:
```css
.action-button,
.primary-button,
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
border-radius: 12px;
border: 1px solid transparent;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: transform var(--ui-transition-fast), box-shadow var(--ui-transition-fast), background var(--ui-transition-fast), border-color var(--ui-transition-fast);
}
.action-button:hover,
.primary-button:hover,
.ghost-button:hover {
transform: translateY(-1px);
}
.action-button:active,
.primary-button:active,
.ghost-button:active {
transform: translateY(0);
}
.action-button-primary,
.primary-button {
color: #fff;
background: linear-gradient(135deg, var(--ui-steel), var(--ui-cyan));
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.22);
}
.action-button-secondary,
.ghost-button {
color: var(--ui-ink-soft);
background: rgba(255, 255, 255, 0.86);
border-color: var(--ui-line);
}
.action-button-danger,
.danger-ghost {
color: var(--ui-red);
background: rgba(254, 242, 242, 0.92);
border-color: rgba(220, 38, 38, 0.22);
}
.action-button-sm {
min-height: 32px;
padding: 0 10px;
font-size: 12px;
}
.action-button-md {
padding: 0 14px;
font-size: 13px;
}
.action-button-lg {
min-height: 44px;
padding: 0 18px;
font-size: 14px;
}
.field-hint {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-left: 6px;
color: var(--ui-ink-muted);
cursor: help;
}
.field-hint-popover {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
z-index: 30;
width: max-content;
max-width: 280px;
transform: translateX(-50%) translateY(4px);
opacity: 0;
pointer-events: none;
padding: 10px 12px;
border-radius: 12px;
color: #fff;
background: rgba(15, 23, 42, 0.92);
box-shadow: var(--ui-shadow-float);
font-size: 12px;
line-height: 1.5;
transition: opacity var(--ui-transition-fast), transform var(--ui-transition-fast);
}
.field-hint:hover .field-hint-popover,
.field-hint:focus-visible .field-hint-popover {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
```
- [ ] **Step 4: Update drawer header close button**
Modify `frontend/src/components/FormDrawer.vue`:
```vue
<button class="ghost-button icon-button" type="button" aria-label="关闭" @click="emit('close')">
<AppIcon name="close" />
</button>
```
Import:
```js
import AppIcon from "./AppIcon.vue";
```
- [ ] **Step 5: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open a purchase order drawer.
Close button renders fully and is not clipped.
Primary and secondary actions have consistent height and radius.
Helper hints appear on hover/focus instead of occupying permanent wide blocks.
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/components/ActionButton.vue frontend/src/components/FieldHint.vue frontend/src/components/FormDrawer.vue frontend/src/styles/main.css
git commit -m "style: add shared action and hint components"
```
---
### Task 5: Chinese-First Status System
**Files:**
- Create: `frontend/src/utils/statusToneMap.js`
- Create: `frontend/src/components/StatusBadge.vue`
- Modify: `frontend/src/utils/statusBadges.js`
- Modify: `frontend/src/styles/main.css`
- Modify: selected views to use `StatusBadge`
- [ ] **Step 1: Create status tone dictionary**
Create `frontend/src/utils/statusToneMap.js`:
```js
export const STATUS_LABELS = {
ACTIVE: "启用",
INACTIVE: "停用",
CREATED: "已创建",
RELEASED: "已下达",
ORDERED: "已下单",
IN_PROGRESS: "进行中",
PARTIAL: "部分完成",
PENDING: "待处理",
PENDING_QC: "待质检",
OPEN: "进行中",
LOCKED: "锁定",
PAUSED: "暂停",
READY_TO_RECEIPT: "待成品入库",
HAS_RETURN: "存在退货",
PASSED: "已放行",
PASS: "已放行",
COMPLETED: "已完成",
SETTLED: "已结单",
DONE: "已完成",
CONFIRMED: "已确认",
POSTED: "已过账",
RECEIVED: "已收货",
REJECTED: "已拒收",
REJECT: "已拒收",
SCRAP: "已报废",
DEPLETED: "已耗尽",
CLOSED: "已关闭"
};
export const STATUS_TONE_GROUPS = {
success: ["启用", "正常", "成功", "可用", "已放行", "放行", "已完成", "已完工", "已结单", "已确认", "已过账", "已收货", "已入库", "已审核", "已同步", "已匹配"],
primary: ["已创建", "已下达", "已下单", "进行中", "部分完成", "小程序清单"],
warning: ["待处理", "待质检", "待放行", "未开始", "锁定", "锁库", "锁单", "暂停", "返工", "返工中", "待成品入库", "待审核", "待同步", "未同步", "未匹配", "待匹配", "未关联"],
orange: ["存在退货", "快到期", "即将到期", "超预警"],
danger: ["停用", "异常", "失败", "报废", "已报废", "拒收", "已拒收", "已耗尽", "已关闭", "已替代", "已覆盖", "已过期", "已驳回", "驳回", "审核失败", "同步失败", "匹配失败"],
neutral: ["草稿", "无数据", "未维护", "历史记录"]
};
export function normalizeStatusValue(value) {
return String(value || "").replace(/[:]/g, "").replace(/\s+/g, "").trim();
}
export function getStatusLabel(value) {
const raw = String(value || "").trim();
if (!raw || raw === "-") return "-";
return STATUS_LABELS[raw.toUpperCase()] || raw;
}
export function getStatusTone(value) {
const label = normalizeStatusValue(getStatusLabel(value));
if (!label || label === "-") return "neutral";
const matched = Object.entries(STATUS_TONE_GROUPS).find(([, values]) =>
values.some((item) => normalizeStatusValue(item) === label)
);
return matched?.[0] || "info";
}
```
- [ ] **Step 2: Create status badge component**
Create `frontend/src/components/StatusBadge.vue`:
```vue
<template>
<span class="status-badge" :class="`status-badge-${tone}`">
<span class="status-badge-dot" aria-hidden="true"></span>
<span>{{ label }}</span>
</span>
</template>
<script setup>
import { computed } from "vue";
import { getStatusLabel, getStatusTone } from "../utils/statusToneMap";
const props = defineProps({
value: {
type: [String, Number, Boolean],
default: ""
},
text: {
type: String,
default: ""
}
});
const label = computed(() => props.text || getStatusLabel(props.value));
const tone = computed(() => getStatusTone(label.value));
</script>
```
- [ ] **Step 3: Add badge styles**
Append to `frontend/src/styles/main.css`:
```css
.status-badge,
.status-badge-auto {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
max-width: 100%;
padding: 0 10px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.status-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 14%, transparent);
}
.status-badge-success,
.status-tone-success {
color: var(--ui-green);
background: rgba(22, 163, 74, 0.1);
border-color: rgba(22, 163, 74, 0.22);
}
.status-badge-primary,
.status-tone-primary {
color: var(--ui-steel);
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.22);
}
.status-badge-warning,
.status-tone-warning {
color: var(--ui-amber);
background: rgba(217, 119, 6, 0.11);
border-color: rgba(217, 119, 6, 0.24);
}
.status-badge-orange,
.status-tone-orange {
color: var(--ui-orange);
background: rgba(234, 88, 12, 0.1);
border-color: rgba(234, 88, 12, 0.24);
}
.status-badge-danger,
.status-tone-danger {
color: var(--ui-red);
background: rgba(220, 38, 38, 0.1);
border-color: rgba(220, 38, 38, 0.24);
}
.status-badge-neutral,
.status-tone-neutral,
.status-badge-info,
.status-tone-info {
color: var(--ui-gray);
background: rgba(100, 116, 139, 0.1);
border-color: rgba(100, 116, 139, 0.2);
}
```
- [ ] **Step 4: Refactor DOM status enhancer to use dictionary**
Modify `frontend/src/utils/statusBadges.js` so it imports the new dictionary:
```js
import { getStatusLabel, getStatusTone, normalizeStatusValue } from "./statusToneMap";
```
Where the old code displays text, ensure English status is translated:
```js
function markStatusElement(element, value) {
const label = getStatusLabel(value);
const tone = getStatusTone(label);
const toneClass = tone ? `${STATUS_TONE_CLASS_PREFIX}${tone}` : "";
clearStatusToneClasses(element, toneClass);
element.classList.toggle("status-badge-auto", Boolean(tone));
element.classList.toggle("status-value-cell", element.tagName === "TD" && Boolean(tone));
if (label && label !== element.textContent.trim()) {
element.textContent = label;
}
if (tone) {
element.classList.add(toneClass);
}
}
```
- [ ] **Step 5: Replace obvious inline status cells**
Start with these views:
```text
frontend/src/views/ProductionLedgerView.vue
frontend/src/views/DeliveryManagementView.vue
frontend/src/views/QualityInspectionView.vue
frontend/src/views/SystemPermissionView.vue
```
For each status cell, replace:
```vue
<td>{{ row.status }}</td>
```
with:
```vue
<td><StatusBadge :value="row.status" /></td>
```
Import:
```js
import StatusBadge from "../components/StatusBadge.vue";
```
- [ ] **Step 6: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open /purchase-orders and confirm English statuses are not visible.
Open /production-ledger and confirm 锁单 uses a warning/amber badge consistently.
Open /delivery-management and confirm 已过账 uses a success badge consistently.
Open /system-permissions and confirm 启用/停用 have different colors.
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/utils/statusToneMap.js frontend/src/components/StatusBadge.vue frontend/src/utils/statusBadges.js frontend/src/styles/main.css frontend/src/views/ProductionLedgerView.vue frontend/src/views/DeliveryManagementView.vue frontend/src/views/QualityInspectionView.vue frontend/src/views/SystemPermissionView.vue
git commit -m "style: unify Chinese status badges"
```
---
### Task 6: Data Table System Upgrade
**Files:**
- Modify: `frontend/src/components/TableControls.vue`
- Modify: `frontend/src/components/DrawerDataTable.vue`
- Modify: `frontend/src/styles/main.css`
- Modify: high-frequency views using `.data-table`
- [ ] **Step 1: Replace raw sort arrow text**
In `frontend/src/components/TableControls.vue`, update `refreshHeaderStates()`:
```js
if (arrow) {
arrow.innerHTML = isActive
? (props.sortDirection === "asc" ? "↑" : "↓")
: "";
arrow.setAttribute("aria-label", isActive ? (props.sortDirection === "asc" ? "升序" : "降序") : "可排序");
arrow.classList.toggle("sort-arrow-active", isActive);
}
```
When creating the arrow, use:
```js
const arrow = document.createElement("span");
arrow.className = "sort-arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = "";
```
- [ ] **Step 2: Add professional table CSS**
Append to `frontend/src/styles/main.css`:
```css
.table-wrap,
.drawer-data-table-wrap {
position: relative;
overflow: auto;
border: 1px solid var(--ui-line);
border-radius: var(--ui-radius-lg);
background: var(--ui-bg-panel-solid);
box-shadow: var(--ui-shadow-card);
}
.table-wrap::after,
.drawer-data-table-wrap::after {
content: "";
position: sticky;
right: 0;
top: 0;
float: right;
width: 28px;
height: 1px;
pointer-events: none;
box-shadow: -18px 0 24px rgba(15, 23, 42, 0.08);
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: auto;
color: var(--ui-ink);
}
.data-table th {
position: sticky;
top: 0;
z-index: 2;
height: 42px;
padding: 0 14px;
color: var(--ui-ink-soft);
background: linear-gradient(180deg, #f8fafc, #eef3f8);
border-bottom: 1px solid var(--ui-line-strong);
font-size: 12px;
font-weight: 900;
text-align: left;
white-space: nowrap;
}
.data-table td {
height: 44px;
padding: 0 14px;
border-bottom: 1px solid rgba(58, 82, 105, 0.08);
color: var(--ui-ink);
font-size: 13px;
vertical-align: middle;
}
.data-table tbody tr {
transition: background var(--ui-transition-fast), box-shadow var(--ui-transition-fast);
}
.data-table tbody tr:hover td {
background: rgba(37, 99, 235, 0.045);
}
.sortable-th-button {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
border: 0;
padding: 0;
color: inherit;
background: transparent;
font-weight: inherit;
cursor: pointer;
}
.sort-arrow {
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 6px;
color: var(--ui-ink-muted);
background: rgba(100, 116, 139, 0.08);
}
.sort-arrow:empty::before {
content: "";
width: 8px;
height: 8px;
border: 1.5px solid currentColor;
border-left: 0;
border-bottom: 0;
transform: rotate(135deg);
opacity: 0.42;
}
.sortable-th-active .sort-arrow {
color: #fff;
background: var(--ui-steel);
}
```
- [ ] **Step 3: Add table density rules**
Append:
```css
.compact-table th {
height: 38px;
}
.compact-table td {
height: 40px;
font-size: 12.5px;
}
.data-table .link-button,
.data-table .strong-link {
color: var(--ui-steel);
font-weight: 800;
text-decoration: none;
}
.data-table .link-button:hover,
.data-table .strong-link:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.empty-row {
height: 96px;
color: var(--ui-ink-muted);
text-align: center;
background: linear-gradient(135deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.9));
}
```
- [ ] **Step 4: Improve drawer table compact labels**
Modify `frontend/src/components/DrawerDataTable.vue` header cell:
```vue
<th v-for="column in visibleColumns" :key="column.key" :title="column.title || column.label">
<span class="drawer-table-head-label">{{ column.compactLabel || column.label }}</span>
</th>
```
Ensure CSS:
```css
.drawer-table-head-label {
display: inline-block;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.drawer-cell-ellipsis {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
```
- [ ] **Step 5: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open / and confirm no visible ↕ remains in table headers.
Open /purchase-orders and confirm table remains readable.
Open /finance-reports and confirm large table still scrolls horizontally but has cleaner header treatment.
Open a detail drawer with drawer table and confirm headers do not turn vertical or wrap awkwardly.
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/components/TableControls.vue frontend/src/components/DrawerDataTable.vue frontend/src/styles/main.css
git commit -m "style: upgrade data table readability"
```
---
### Task 7: Smart Drawer And Form Section System
**Files:**
- Create: `frontend/src/components/SmartDrawer.vue`
- Create: `frontend/src/components/FormSection.vue`
- Create: `frontend/src/components/MetricStrip.vue`
- Modify: `frontend/src/components/FormDrawer.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Create SmartDrawer**
Create `frontend/src/components/SmartDrawer.vue`:
```vue
<template>
<Teleport to="body">
<div v-if="open" class="smart-drawer-mask" @click.self="emit('close')">
<aside class="smart-drawer" :class="[`smart-drawer-${size}`]">
<header class="smart-drawer-head">
<div class="smart-drawer-title-block">
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
<h3>{{ title }}</h3>
<p v-if="description" class="smart-drawer-description">{{ description }}</p>
</div>
<div class="smart-drawer-actions">
<slot name="head-actions" />
<button class="ghost-button icon-button" type="button" aria-label="关闭" @click="emit('close')">
<AppIcon name="close" />
</button>
</div>
</header>
<div class="smart-drawer-body">
<slot />
</div>
<footer v-if="$slots.footer" class="smart-drawer-footer">
<slot name="footer" />
</footer>
</aside>
</div>
</Teleport>
</template>
<script setup>
import { toRef } from "vue";
import AppIcon from "./AppIcon.vue";
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
const props = defineProps({
open: { type: Boolean, default: false },
eyebrow: { type: String, default: "" },
title: { type: String, required: true },
description: { type: String, default: "" },
size: { type: String, default: "lg" }
});
const emit = defineEmits(["close"]);
useBodyScrollLock(toRef(props, "open"));
</script>
```
- [ ] **Step 2: Create FormSection**
Create `frontend/src/components/FormSection.vue`:
```vue
<template>
<section class="form-section">
<header v-if="title || description || $slots.actions" class="form-section-head">
<div>
<h4 v-if="title">{{ title }}</h4>
<p v-if="description">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="form-section-actions">
<slot name="actions" />
</div>
</header>
<div class="form-section-grid" :class="`form-section-grid-${columns}`">
<slot />
</div>
</section>
</template>
<script setup>
defineProps({
title: { type: String, default: "" },
description: { type: String, default: "" },
columns: { type: Number, default: 2 }
});
</script>
```
- [ ] **Step 3: Create MetricStrip**
Create `frontend/src/components/MetricStrip.vue`:
```vue
<template>
<div class="metric-strip">
<article v-for="metric in metrics" :key="metric.label" class="metric-strip-item">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small v-if="metric.hint">{{ metric.hint }}</small>
</article>
</div>
</template>
<script setup>
defineProps({
metrics: {
type: Array,
default: () => []
}
});
</script>
```
- [ ] **Step 4: Add drawer and form CSS**
Append to `frontend/src/styles/main.css`:
```css
.smart-drawer-mask {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
justify-content: flex-end;
padding: 14px;
background: rgba(15, 23, 42, 0.34);
backdrop-filter: blur(8px);
}
.smart-drawer {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(1080px, calc(100vw - 28px));
height: calc(100vh - 28px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.66);
border-radius: 24px;
background: rgba(248, 250, 252, 0.96);
box-shadow: var(--ui-shadow-float);
animation: smartDrawerIn var(--ui-transition-normal);
}
.smart-drawer-xl {
width: min(1280px, calc(100vw - 28px));
}
.smart-drawer-fullscreen {
width: calc(100vw - 28px);
}
@keyframes smartDrawerIn {
from {
opacity: 0;
transform: translateX(18px) scale(0.99);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
.smart-drawer-head,
.smart-drawer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 22px;
background: rgba(255, 255, 255, 0.86);
border-bottom: 1px solid var(--ui-line);
}
.smart-drawer-footer {
justify-content: flex-end;
border-top: 1px solid var(--ui-line);
border-bottom: 0;
box-shadow: 0 -12px 28px rgba(15, 23, 42, 0.06);
}
.smart-drawer-body {
min-height: 0;
overflow: auto;
padding: 20px 22px;
}
.smart-drawer-title-block h3 {
margin: 0;
color: var(--ui-ink);
font-size: 22px;
letter-spacing: -0.02em;
}
.smart-drawer-description {
max-width: 760px;
margin: 6px 0 0;
color: var(--ui-ink-muted);
font-size: 13px;
line-height: 1.5;
}
.form-section {
padding: 18px;
border: 1px solid var(--ui-line);
border-radius: var(--ui-radius-lg);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
}
.form-section + .form-section {
margin-top: 16px;
}
.form-section-head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.form-section-head h4 {
margin: 0;
font-size: 15px;
color: var(--ui-ink);
}
.form-section-head p {
margin: 4px 0 0;
color: var(--ui-ink-muted);
font-size: 12px;
}
.form-section-grid {
display: grid;
gap: 14px;
}
.form-section-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-section-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-section label {
display: grid;
gap: 7px;
color: var(--ui-ink-soft);
font-size: 12px;
font-weight: 800;
}
.form-section input,
.form-section select,
.form-section textarea {
min-height: 38px;
width: 100%;
border: 1px solid var(--ui-line);
border-radius: 12px;
padding: 0 12px;
color: var(--ui-ink);
background: rgba(255, 255, 255, 0.96);
}
.form-section textarea {
min-height: 84px;
padding: 10px 12px;
resize: vertical;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.metric-strip-item {
padding: 12px;
border: 1px solid var(--ui-line);
border-radius: 14px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(241, 245, 249, 0.9));
}
.metric-strip-item span,
.metric-strip-item small {
display: block;
color: var(--ui-ink-muted);
font-size: 12px;
}
.metric-strip-item strong {
display: block;
margin-top: 4px;
color: var(--ui-ink);
font-size: 18px;
}
@media (max-width: 768px) {
.smart-drawer-mask {
padding: 0;
}
.smart-drawer {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.form-section-grid-2,
.form-section-grid-3 {
grid-template-columns: 1fr;
}
}
```
- [ ] **Step 5: Keep `FormDrawer` backward compatible**
Do not replace all drawers at once. Update `FormDrawer.vue` only with improved header and body classes. Future page tasks will migrate complex drawers to `SmartDrawer`.
- [ ] **Step 6: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open existing drawers and confirm no drawer became invisible.
At 390x844, drawer becomes full-screen instead of a narrow broken side panel.
At 1440x900, drawer has stable width and fixed action area.
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/SmartDrawer.vue frontend/src/components/FormSection.vue frontend/src/components/MetricStrip.vue frontend/src/components/FormDrawer.vue frontend/src/styles/main.css
git commit -m "style: add smart drawer form system"
```
---
### Task 8: Searchable Select And Long Option Upgrade
**Files:**
- Modify: `frontend/src/components/GlobalSearchableSelect.vue`
- Modify: `frontend/src/components/StockLotTagSelect.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Parse option metadata for display**
In `frontend/src/components/GlobalSearchableSelect.vue`, replace `collectOptions` with metadata-aware parsing:
```js
function splitOptionLabel(label) {
const parts = cleanOptionLabel(label).split(" · ").filter(Boolean);
return {
title: parts[0] || label,
subtitle: parts.slice(1, 3).join(" · "),
meta: parts.slice(3).join(" · ")
};
}
function collectOptions(selectEl) {
return Array.from(selectEl.options || []).map((option, index) => {
const label = cleanOptionLabel(option.textContent);
return {
index,
value: option.value,
label,
display: splitOptionLabel(label),
disabled: Boolean(option.disabled || option.parentElement?.disabled),
selected: index === selectEl.selectedIndex
};
});
}
```
- [ ] **Step 2: Update option template**
Replace the option button content with:
```vue
<span class="global-select-option-main">{{ option.display.title || option.label || option.value || "空选项" }}</span>
<small v-if="option.display.subtitle" class="global-select-option-sub">{{ option.display.subtitle }}</small>
<em v-if="option.display.meta" class="global-select-option-meta">{{ option.display.meta }}</em>
```
- [ ] **Step 3: Add select panel styles**
Append to `frontend/src/styles/main.css`:
```css
.global-select-layer {
z-index: 2000;
}
.global-select-input-box {
border-radius: 12px;
box-shadow: var(--ui-shadow-card);
}
.global-select-search-input {
border: 1px solid var(--ui-line-strong);
border-radius: 12px;
padding: 0 36px 0 12px;
color: var(--ui-ink);
background: #fff;
}
.global-select-panel {
overflow: hidden;
border: 1px solid var(--ui-line-strong);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--ui-shadow-float);
}
.global-select-option-list {
max-height: inherit;
overflow: auto;
padding: 6px;
}
.global-select-option {
display: grid;
gap: 3px;
width: 100%;
border: 0;
border-radius: 12px;
padding: 10px 12px;
color: var(--ui-ink);
background: transparent;
text-align: left;
}
.global-select-option:hover,
.global-select-option-active {
background: rgba(37, 99, 235, 0.08);
}
.global-select-option-selected {
color: var(--ui-steel);
background: rgba(37, 99, 235, 0.12);
}
.global-select-option-main {
font-weight: 900;
}
.global-select-option-sub,
.global-select-option-meta {
color: var(--ui-ink-muted);
font-size: 12px;
font-style: normal;
line-height: 1.35;
}
.global-select-option-disabled {
opacity: 0.45;
cursor: not-allowed;
}
```
- [ ] **Step 4: Align `StockLotTagSelect` dropdown behavior**
Modify `frontend/src/components/StockLotTagSelect.vue` so every selected lot closes the dropdown and requires the user to click the input again to add another lot:
```js
function selectLot(lot) {
if (!lot || isSelected(lot) || isDisabled(lot)) {
return;
}
emit("update:modelValue", [...props.modelValue, lot.lot_no]);
closeDropdown();
}
```
Keep searchable behavior and selected tags.
- [ ] **Step 5: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open purchase order drawer and click 原材料 select.
Confirm long options display as title/subtitle/meta instead of one huge line.
Open a warehouse stock lot multi-select.
Select one lot and confirm dropdown closes.
Click input again and confirm dropdown reopens for next selection.
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/components/GlobalSearchableSelect.vue frontend/src/components/StockLotTagSelect.vue frontend/src/styles/main.css
git commit -m "style: improve searchable select option readability"
```
---
### Task 9: Warehouse Page Visual Upgrade
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/styles/main.css`
- Use: `AppIcon.vue`, `StatusBadge.vue`, `SmartDrawer.vue`, `MetricStrip.vue` where practical
- [ ] **Step 1: Rebuild warehouse selector as cards**
In `frontend/src/views/InventoryLedgerView.vue`, update the warehouse selector markup to this shape while preserving existing click handlers and active warehouse state:
```vue
<div class="warehouse-switchboard">
<button
v-for="warehouse in warehouseTabs"
:key="warehouse.key"
class="warehouse-tab-card"
:class="{ 'warehouse-tab-card-active': activeWarehouse === warehouse.key }"
type="button"
@click="selectWarehouse(warehouse.key)"
>
<AppIcon :name="warehouse.icon || 'warehouse'" />
<span>{{ warehouse.label }}</span>
<strong>{{ warehouse.count }} </strong>
</button>
</div>
```
If current data uses another variable name, map to that variable without changing its business value.
- [ ] **Step 2: Rebuild operation zone**
Use this structure for 入库/出库/工具 buttons:
```vue
<section class="warehouse-operation-zone">
<div class="warehouse-operation-group warehouse-operation-in">
<span class="warehouse-operation-title">入库</span>
<button v-for="operation in inboundOperations" :key="operation.key" class="warehouse-operation-button" type="button" @click="openOperation(operation)">
<AppIcon name="plus" tone="success" />
<span>{{ operation.label }}</span>
</button>
</div>
<div class="warehouse-operation-group warehouse-operation-out">
<span class="warehouse-operation-title">出库</span>
<button v-for="operation in outboundOperations" :key="operation.key" class="warehouse-operation-button" type="button" @click="openOperation(operation)">
<AppIcon name="minus" tone="danger" />
<span>{{ operation.label }}</span>
</button>
</div>
<div class="warehouse-operation-group warehouse-operation-tools">
<button class="warehouse-tool-button" type="button" @click="openLedger">
<AppIcon name="ledger" />
<span>流水</span>
</button>
<button class="warehouse-tool-button warehouse-tool-primary" type="button" @click="openProductionWorkOrderInbound">
<AppIcon name="production" />
<span>生产台账入库</span>
</button>
</div>
</section>
```
- [ ] **Step 3: Add warehouse CSS**
Append to `frontend/src/styles/main.css`:
```css
.warehouse-switchboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin: 14px 0;
}
.warehouse-tab-card {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 52px;
border: 1px solid var(--ui-line);
border-radius: 16px;
padding: 0 14px;
color: var(--ui-ink-soft);
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.warehouse-tab-card-active {
color: var(--ui-steel);
border-color: rgba(37, 99, 235, 0.28);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(219, 234, 254, 0.62));
box-shadow: 0 16px 36px rgba(37, 99, 235, 0.13);
}
.warehouse-tab-card span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 900;
}
.warehouse-tab-card strong {
color: inherit;
font-size: 12px;
}
.warehouse-operation-zone {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 12px;
margin-bottom: 14px;
padding: 14px;
border: 1px solid var(--ui-line);
border-radius: var(--ui-radius-lg);
background: rgba(255, 255, 255, 0.76);
box-shadow: var(--ui-shadow-card);
}
.warehouse-operation-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.warehouse-operation-title {
flex: 0 0 auto;
min-width: 42px;
color: var(--ui-ink-muted);
font-size: 12px;
font-weight: 900;
}
.warehouse-operation-button,
.warehouse-tool-button {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 34px;
border: 1px solid var(--ui-line);
border-radius: 999px;
padding: 0 12px;
color: var(--ui-ink-soft);
background: rgba(255, 255, 255, 0.92);
font-size: 12px;
font-weight: 900;
}
.warehouse-operation-in .warehouse-operation-button {
color: var(--ui-green);
border-color: rgba(22, 163, 74, 0.18);
background: rgba(22, 163, 74, 0.08);
}
.warehouse-operation-out .warehouse-operation-button {
color: var(--ui-red);
border-color: rgba(220, 38, 38, 0.18);
background: rgba(220, 38, 38, 0.07);
}
.warehouse-tool-primary {
color: #fff;
background: linear-gradient(135deg, var(--ui-steel), var(--ui-cyan));
border-color: transparent;
}
@media (max-width: 1080px) {
.warehouse-operation-zone {
grid-template-columns: 1fr;
}
}
```
- [ ] **Step 4: Reduce permanent explanatory text**
In `InventoryLedgerView.vue`, replace long visible operation descriptions with compact title attributes or `FieldHint`. Keep operation label visible. Example:
```vue
<button class="warehouse-operation-button" :title="operation.description" type="button" @click="openOperation(operation)">
<AppIcon :name="operation.direction === 'in' ? 'plus' : 'minus'" />
<span>{{ operation.label }}</span>
</button>
```
- [ ] **Step 5: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open /inventory-ledger.
Confirm warehouse cards are readable and active warehouse is obvious.
Confirm 入库 and 出库 operations are visually separated.
Confirm 流水 and 生产台账入库 are visible near operation tools.
Confirm no operation label contains the word bar.
Confirm table remains usable at 1440x900 and 390x844.
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/InventoryLedgerView.vue frontend/src/styles/main.css
git commit -m "style: refine warehouse operation experience"
```
---
### Task 10: Procurement, Sales, Production, Delivery Page Pass
**Files:**
- Modify: `frontend/src/views/PurchaseOrderView.vue`
- Modify: `frontend/src/views/PurchaseReceiptView.vue`
- Modify: `frontend/src/views/QualityInspectionView.vue`
- Modify: `frontend/src/views/SalesPlanningView.vue`
- Modify: `frontend/src/views/ProductionLedgerView.vue`
- Modify: `frontend/src/views/OperationReportView.vue`
- Modify: `frontend/src/views/DeliveryManagementView.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Standardize page header rhythm**
For each listed view, ensure the top panel follows this shape:
```vue
<section class="page-hero-card">
<div>
<p class="eyebrow">{{ pageEyebrow }}</p>
<h2>{{ pageTitle }}</h2>
<p class="page-hero-subtitle">{{ pageSubtitle }}</p>
</div>
<div class="page-hero-actions">
<button class="ghost-button" type="button" @click="loadData">刷新</button>
<button v-if="canCreate" class="primary-button" type="button" @click="openCreateDrawer">{{ createLabel }}</button>
</div>
</section>
```
Use existing variables and handlers. Do not add new business actions.
- [ ] **Step 2: Add page hero CSS**
Append to `frontend/src/styles/main.css`:
```css
.page-hero-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 14px;
padding: 18px 20px;
border: 1px solid var(--ui-line);
border-radius: var(--ui-radius-xl);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(241, 245, 249, 0.88)),
radial-gradient(circle at 100% 0%, rgba(37, 99, 235, 0.12), transparent 34%);
box-shadow: var(--ui-shadow-card);
}
.page-hero-card h2 {
margin: 0;
color: var(--ui-ink);
font-size: 22px;
letter-spacing: -0.02em;
}
.page-hero-subtitle {
margin: 6px 0 0;
color: var(--ui-ink-muted);
font-size: 13px;
}
.page-hero-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
@media (max-width: 768px) {
.page-hero-card {
display: grid;
}
.page-hero-actions {
justify-content: flex-start;
}
}
```
- [ ] **Step 3: Migrate create/edit drawers gradually**
For each create/edit drawer in the listed views, keep the existing form fields and handlers but wrap field clusters in `FormSection` where it improves readability. Example for purchase order:
```vue
<FormSection title="采购基础信息" :columns="3">
<label>
<span>采购单号</span>
<input v-model.trim="purchaseForm.order_no" type="text" required />
</label>
<label>
<span>供应商</span>
<select v-model="purchaseForm.supplier_id" required>
<option disabled value="">请选择供应商</option>
<option v-for="supplier in suppliers" :key="supplier.supplier_id" :value="supplier.supplier_id">
{{ supplier.supplier_name }}
</option>
</select>
</label>
<label>
<span>预计到货</span>
<input v-model="purchaseForm.expected_arrival_date" type="date" />
</label>
</FormSection>
```
Import:
```js
import FormSection from "../components/FormSection.vue";
```
- [ ] **Step 4: Convert status cells**
For each listed page, replace status plain text with `StatusBadge`. Example:
```vue
<td><StatusBadge :value="row.status" /></td>
```
- [ ] **Step 5: Convert noisy helper blocks**
Replace visible helper paragraphs longer than 48 characters with one of:
```vue
<FieldHint text="完整说明文字放在这里,用户需要时悬停查看。" />
```
or a compact title:
```vue
<span class="panel-tag" :title="longDescription">规则说明</span>
```
Do not delete the meaning. Only move it out of permanent page flow.
- [ ] **Step 6: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open /purchase-orders and create drawer. Confirm all existing fields still exist.
Open /order-list and create drawer. Confirm all existing fields still exist.
Open /production-ledger and confirm status colors are clear.
Open /delivery-management and confirm grouped delivery table remains readable.
Open /operation-reports and confirm date filters look integrated, not bolted on.
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/views/PurchaseOrderView.vue frontend/src/views/PurchaseReceiptView.vue frontend/src/views/QualityInspectionView.vue frontend/src/views/SalesPlanningView.vue frontend/src/views/ProductionLedgerView.vue frontend/src/views/OperationReportView.vue frontend/src/views/DeliveryManagementView.vue frontend/src/styles/main.css
git commit -m "style: polish high frequency business pages"
```
---
### Task 11: Finance Large Table And System Permission Polish
**Files:**
- Modify: `frontend/src/views/FinanceReportView.vue`
- Modify: `frontend/src/views/SystemPermissionView.vue`
- Modify: `frontend/src/components/OrgMindMap.vue`
- Modify: `frontend/src/components/OrgPermissionTree.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add financial spreadsheet mode styles**
Append to `frontend/src/styles/main.css`:
```css
.finance-sheet-table {
border-collapse: separate;
border-spacing: 0;
background: #fff;
}
.finance-sheet-table th {
position: sticky;
top: 0;
z-index: 3;
background: linear-gradient(180deg, #f8fafc, #e2e8f0);
color: var(--ui-ink-soft);
white-space: nowrap;
font-size: 12px;
font-weight: 900;
}
.finance-sheet-table td {
color: var(--ui-ink);
white-space: nowrap;
}
.finance-sticky-cell {
position: sticky;
left: 0;
z-index: 2;
background: #fff;
box-shadow: 10px 0 18px rgba(15, 23, 42, 0.06);
}
.finance-board-toolbar {
border: 1px solid var(--ui-line);
border-radius: var(--ui-radius-lg);
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--ui-shadow-card);
}
```
- [ ] **Step 2: Improve finance filter row**
In `FinanceReportView.vue`, wrap date filters and refresh actions in a compact filter bar:
```vue
<div class="filter-ribbon">
<label>
<span>开始日期</span>
<input v-model="dateFrom" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="dateTo" type="date" />
</label>
<button class="ghost-button" type="button" @click="expenseDrawerOpen = true">费用登记</button>
<button class="primary-button" type="button" :disabled="loadingBoard" @click="loadBoard">刷新核算表</button>
</div>
```
- [ ] **Step 3: Add filter ribbon CSS**
Append:
```css
.filter-ribbon {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
padding: 14px;
border: 1px solid var(--ui-line);
border-radius: var(--ui-radius-lg);
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--ui-shadow-card);
}
.filter-ribbon label {
display: grid;
gap: 6px;
min-width: 180px;
color: var(--ui-ink-soft);
font-size: 12px;
font-weight: 900;
}
.filter-ribbon input,
.filter-ribbon select {
min-height: 38px;
border: 1px solid var(--ui-line);
border-radius: 12px;
padding: 0 12px;
background: #fff;
}
```
- [ ] **Step 4: Polish system permission tabs**
In `SystemPermissionView.vue`, keep the existing two tab model but style as segmented tabs:
```vue
<div class="admin-tab-switcher">
<button :class="{ active: activeTab === 'org' }" type="button" @click="activeTab = 'org'">组织架构</button>
<button :class="{ active: activeTab === 'role' }" type="button" @click="activeTab = 'role'">角色维护</button>
</div>
```
- [ ] **Step 5: Add admin tab CSS**
Append:
```css
.admin-tab-switcher {
display: inline-flex;
gap: 6px;
padding: 6px;
border: 1px solid var(--ui-line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.76);
}
.admin-tab-switcher button {
min-height: 34px;
border: 0;
border-radius: 12px;
padding: 0 14px;
color: var(--ui-ink-soft);
background: transparent;
font-weight: 900;
}
.admin-tab-switcher button.active {
color: #fff;
background: linear-gradient(135deg, var(--ui-steel), var(--ui-cyan));
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.18);
}
```
- [ ] **Step 6: Verify**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
Browser checks:
```text
Open /finance-reports and confirm filters are not stretched to huge widths.
Confirm financial table remains horizontally scrollable and key columns stay readable.
Open /system-permissions and confirm tabs do not crowd the brain map or tree.
Confirm org map controls are visually consistent with other buttons.
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/views/FinanceReportView.vue frontend/src/views/SystemPermissionView.vue frontend/src/components/OrgMindMap.vue frontend/src/components/OrgPermissionTree.vue frontend/src/styles/main.css
git commit -m "style: polish finance and permission interfaces"
```
---
### Task 12: Final UI Verification And Regression Sweep
**Files:**
- Modify only if verification finds issues.
- Test: frontend build and browser screenshots.
- [ ] **Step 1: Build**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected:
```text
✓ built
```
- [ ] **Step 2: Desktop browser sweep**
Use browser plugin at `1440x900`:
```text
http://127.0.0.1:5173/
http://127.0.0.1:5173/customer-management
http://127.0.0.1:5173/supplier-management
http://127.0.0.1:5173/supplier-material-lite
http://127.0.0.1:5173/product-spec-lite
http://127.0.0.1:5173/order-list
http://127.0.0.1:5173/purchase-orders
http://127.0.0.1:5173/purchase-receipts
http://127.0.0.1:5173/quality-inspection
http://127.0.0.1:5173/inventory-ledger
http://127.0.0.1:5173/production-ledger
http://127.0.0.1:5173/operation-reports
http://127.0.0.1:5173/delivery-management
http://127.0.0.1:5173/finance-reports
http://127.0.0.1:5173/system-permissions
http://127.0.0.1:5173/system-extension
```
Expected:
```text
No page is squeezed by sidebar.
No obvious raw English status is visible.
No table header shows raw ↕ text.
No drawer close/check button is clipped.
No form field is hidden by footer.
No long helper paragraph dominates the page.
```
- [ ] **Step 3: Narrow browser sweep**
Use browser plugin at `390x844`:
```text
http://127.0.0.1:5173/
http://127.0.0.1:5173/inventory-ledger
http://127.0.0.1:5173/purchase-orders
http://127.0.0.1:5173/order-list
http://127.0.0.1:5173/system-permissions
```
Expected:
```text
Sidebar collapses.
Main content remains usable.
Tables scroll inside their wrappers.
Drawers are full-screen or near full-screen.
Primary action buttons remain visible.
```
- [ ] **Step 4: Interaction checks**
Perform these checks without saving destructive data:
```text
Open purchase order create drawer.
Open sales order create drawer.
Open warehouse 客料入库 drawer.
Open warehouse stock lot multi-select and select one item.
Open finance expense drawer.
Switch system permission tabs.
Switch org brain map/tree mode.
```
Expected:
```text
Existing fields are still present.
Dropdowns are searchable.
Multi-select closes after one selection and reopens on next click.
Status badges remain colored.
Buttons have consistent height and feedback.
```
- [ ] **Step 5: Run UI audit helper in browser console**
On `/inventory-ledger`, `/purchase-orders`, and `/finance-reports`, run:
```js
import("/src/utils/uiAudit.js").then((m) => m.printUiAuditSnapshot());
```
Expected:
```text
visibleEnglishStatuses is empty or contains only internal hidden values.
tables do not report hasRawSortText as true.
longHelperBlocks contains no large permanent business explanations.
```
- [ ] **Step 6: Final commit**
```bash
git add frontend/src
git commit -m "style: complete ERP frontend visual upgrade"
```
If no `.git` directory exists, record:
```text
Final commit skipped because current workspace is not a git repository.
```
---
## Self-Review
- **Spec coverage:** The plan covers global visual direction, five requested design skills, shell, icons, buttons, helper text, status colors, tables, drawers, searchable selects, warehouse page, high-frequency pages, finance, system permissions, desktop/narrow verification.
- **Scope boundary:** The plan keeps business fields, workflows, API payloads, and backend behavior unchanged.
- **No placeholder scan:** The plan contains concrete file paths, code snippets, verification commands, and expected results.
- **Type consistency:** New components use stable props: `AppIcon.name`, `StatusBadge.value`, `SmartDrawer.open/title/size`, `FormSection.title/columns`, `MetricStrip.metrics`.
- **Execution recommendation:** Use subagent-driven development. Split by primitive/component tasks first, then page passes, because the table/status/drawer work can be reviewed independently before touching high-frequency business pages.