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