Add optimization branch updates
This commit is contained in:
parent
64e7ac346d
commit
2a00248ac3
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.impeccable/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.log
|
*.log
|
||||||
outputs/
|
outputs/
|
||||||
|
|||||||
229
DESIGN.md
Normal file
229
DESIGN.md
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
name: ForgeFlow ERP
|
||||||
|
description: A clear operational ERP interface for hardware manufacturing workflows.
|
||||||
|
colors:
|
||||||
|
canvas: "#f0f2f5"
|
||||||
|
canvas-soft: "#f5f7fb"
|
||||||
|
surface: "#ffffff"
|
||||||
|
surface-subtle: "#fbfdff"
|
||||||
|
text: "#303133"
|
||||||
|
text-strong: "#15202b"
|
||||||
|
text-soft: "#606266"
|
||||||
|
text-muted: "#909399"
|
||||||
|
line: "#ebeef5"
|
||||||
|
line-strong: "#d9e1ec"
|
||||||
|
primary: "#409eff"
|
||||||
|
primary-deep: "#2563eb"
|
||||||
|
primary-soft: "#ecf5ff"
|
||||||
|
info: "#0ea5e9"
|
||||||
|
success: "#67c23a"
|
||||||
|
warning: "#e6a23c"
|
||||||
|
orange: "#d97706"
|
||||||
|
danger: "#f56c6c"
|
||||||
|
typography:
|
||||||
|
title:
|
||||||
|
fontFamily: "Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif"
|
||||||
|
fontSize: "18px"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.35
|
||||||
|
letterSpacing: "0"
|
||||||
|
body:
|
||||||
|
fontFamily: "Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif"
|
||||||
|
fontSize: "14px"
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.55
|
||||||
|
letterSpacing: "0"
|
||||||
|
label:
|
||||||
|
fontFamily: "Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif"
|
||||||
|
fontSize: "12px"
|
||||||
|
fontWeight: 600
|
||||||
|
lineHeight: 1.3
|
||||||
|
letterSpacing: "0"
|
||||||
|
rounded:
|
||||||
|
xs: "4px"
|
||||||
|
sm: "8px"
|
||||||
|
md: "10px"
|
||||||
|
lg: "12px"
|
||||||
|
panel: "14px"
|
||||||
|
spacing:
|
||||||
|
xs: "6px"
|
||||||
|
sm: "8px"
|
||||||
|
md: "12px"
|
||||||
|
lg: "16px"
|
||||||
|
xl: "24px"
|
||||||
|
components:
|
||||||
|
button-primary:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.surface}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "0 14px"
|
||||||
|
height: "38px"
|
||||||
|
button-secondary:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text-soft}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "0 14px"
|
||||||
|
height: "38px"
|
||||||
|
status-success:
|
||||||
|
backgroundColor: "#f0f9eb"
|
||||||
|
textColor: "{colors.success}"
|
||||||
|
rounded: "{rounded.xs}"
|
||||||
|
padding: "2px 8px"
|
||||||
|
status-primary:
|
||||||
|
backgroundColor: "{colors.primary-soft}"
|
||||||
|
textColor: "{colors.primary}"
|
||||||
|
rounded: "{rounded.xs}"
|
||||||
|
padding: "2px 8px"
|
||||||
|
table-container:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text-soft}"
|
||||||
|
rounded: "16px"
|
||||||
|
padding: "0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design System: ForgeFlow ERP
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
**Creative North Star: "The Clear Production Desk"**
|
||||||
|
|
||||||
|
ForgeFlow ERP should feel like a clean production desk in a busy hardware factory: the work is dense, the stakes are real, and the interface keeps records, batches, orders, and responsibilities easy to read. The system is product-first, so familiarity is a feature. It should look mature enough for daily operational use, not like a marketing page or a visual experiment.
|
||||||
|
|
||||||
|
The current codebase contains two visual histories: an older dark industrial theme and a newer light admin theme. The light admin theme is the direction to preserve for routine ERP screens. Dark, animated, highly decorative industrial styling may remain on the login screen or special showcase surfaces, but it should not define ordinary data-entry, table, and workflow pages.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
|
||||||
|
- Light, calm, high-density operational surfaces.
|
||||||
|
- Blue primary actions used sparingly for current selection and key commands.
|
||||||
|
- Chinese-first typography using a practical system sans stack.
|
||||||
|
- Table, drawer, form, pagination, and status components as the core vocabulary.
|
||||||
|
- Formal paper-like styling only for archived business documents and document entry flows.
|
||||||
|
|
||||||
|
## 2. Colors
|
||||||
|
|
||||||
|
The palette is a restrained light ERP system: neutral surfaces carry most of the product, with blue reserved for action, selection, and traceability.
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
- **Operational Blue** (#409eff): current admin accent for active pagination, selected states, focus, and primary actions.
|
||||||
|
- **Trace Blue** (#2563eb): stronger blue already present in `--ui-steel`; use for focus rings, high-emphasis primary actions, and cross-record traceability.
|
||||||
|
|
||||||
|
### Secondary
|
||||||
|
- **Process Cyan** (#0ea5e9): supporting process and information accents; do not use as broad decoration.
|
||||||
|
- **Pass Green** (#67c23a): success and completed states.
|
||||||
|
- **Review Amber** (#e6a23c): warning, pending review, and attention states.
|
||||||
|
- **Stop Red** (#f56c6c): destructive, failed, or blocked states.
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- **ERP Canvas** (#f0f2f5): app shell background.
|
||||||
|
- **Work Surface** (#ffffff): panels, forms, tables, and drawer bodies.
|
||||||
|
- **Soft Surface** (#fbfdff): subtle table and card gradients.
|
||||||
|
- **Main Ink** (#303133): default text.
|
||||||
|
- **Soft Ink** (#606266): secondary text and metadata.
|
||||||
|
- **Muted Ink** (#909399): placeholders and disabled text; verify contrast before using on tinted backgrounds.
|
||||||
|
- **Hairline** (#ebeef5): table and form dividers.
|
||||||
|
|
||||||
|
### Named Rules
|
||||||
|
|
||||||
|
**The Blue Rarity Rule.** Blue should identify where the user is and what they can do next. It should not be used as a decorative wash across every panel.
|
||||||
|
|
||||||
|
**The Light Admin Rule.** Routine ERP screens use light surfaces. Dark industrial styling is reserved for login or explicitly staged visual moments.
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
**Display Font:** The product should avoid a separate display face inside authenticated screens.
|
||||||
|
**Body Font:** `Helvetica Neue`, Helvetica, `PingFang SC`, `Hiragino Sans GB`, `Microsoft YaHei`, Arial, sans-serif.
|
||||||
|
**Label/Mono Font:** No separate mono font is established; codes and batch numbers use the body stack unless a future token is introduced.
|
||||||
|
|
||||||
|
**Character:** Practical Chinese UI typography with a tight product scale. The interface should feel easy to scan in tables and drawers, not editorial or poster-like.
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
- **Title** (700, 18px, 1.35): panel titles, drawer titles, key page headers.
|
||||||
|
- **Section Title** (700, 15-16px, 1.35): form sections, table group headings, workflow blocks.
|
||||||
|
- **Body** (400-500, 14px, 1.55): operational copy, descriptions, table cells.
|
||||||
|
- **Table Header** (750, 12px, 1.25): sticky table headers and sortable labels.
|
||||||
|
- **Label** (600, 12-13px, 1.3): field labels, tags, chips, small controls.
|
||||||
|
|
||||||
|
### Named Rules
|
||||||
|
|
||||||
|
**The Fixed Scale Rule.** Product screens use fixed rem or px sizes, not fluid hero typography. Large `clamp()` headings are only acceptable on intentionally staged login or overview pages.
|
||||||
|
|
||||||
|
**The No Display Labels Rule.** Buttons, labels, chips, table headers, and drawer metadata use the product sans stack only.
|
||||||
|
|
||||||
|
## 4. Elevation
|
||||||
|
|
||||||
|
The current system uses a hybrid of tonal layering, borders, and shadows. The desired direction is flatter by default: panels and tables should rely on background contrast, dividers, and light shadows. High shadows are reserved for drawers, popovers, dropdowns, and active overlays.
|
||||||
|
|
||||||
|
### Shadow Vocabulary
|
||||||
|
- **Low Surface** (`0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06)`): tables, small cards, summary blocks.
|
||||||
|
- **Panel Surface** (`0 12px 28px rgba(31, 45, 61, 0.12), 0 3px 8px rgba(31, 45, 61, 0.08)`): main panels and large cards.
|
||||||
|
- **Floating Surface** (`0 20px 46px rgba(31, 45, 61, 0.16), 0 8px 18px rgba(31, 45, 61, 0.1)`): drawers, popovers, menus, and temporary overlays.
|
||||||
|
- **Focus Ring** (`0 0 0 3px rgba(37, 99, 235, 0.18)`): keyboard focus and active form controls.
|
||||||
|
|
||||||
|
### Named Rules
|
||||||
|
|
||||||
|
**The One Depth Signal Rule.** Do not pair thick borders, large radius, glossy gradients, and heavy shadow on the same element. Pick the depth signal that best explains the state.
|
||||||
|
|
||||||
|
## 5. Components
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
- **Shape:** 10-12px for normal buttons; full pill only for tags and compact chips.
|
||||||
|
- **Primary:** solid blue or restrained blue treatment, 38px default height, 8px icon/text gap.
|
||||||
|
- **Hover / Focus:** small state feedback only. Avoid decorative gradient sweeps and heavy lift.
|
||||||
|
- **Secondary / Ghost:** white or subtle neutral surface with border, no large shadow.
|
||||||
|
- **Danger:** red text or soft red background; reserve filled red for destructive confirmation.
|
||||||
|
|
||||||
|
### Chips
|
||||||
|
- **Style:** compact pill tags for metadata and statuses, with neutral or semantic tint.
|
||||||
|
- **State:** selected chips may use blue text and soft blue background. Inactive chips should stay neutral.
|
||||||
|
|
||||||
|
### Cards / Containers
|
||||||
|
- **Corner Style:** 12-14px for panels and repeated cards.
|
||||||
|
- **Background:** white to soft white gradients are acceptable when subtle.
|
||||||
|
- **Shadow Strategy:** low or panel surface only; avoid decorative ghost-card combinations.
|
||||||
|
- **Border:** 1px neutral border. Avoid colored side stripes.
|
||||||
|
- **Internal Padding:** 16-24px depending on density.
|
||||||
|
|
||||||
|
### Inputs / Fields
|
||||||
|
- **Style:** white or very light background, 1px neutral border, 10-12px radius.
|
||||||
|
- **Focus:** blue focus ring and border shift.
|
||||||
|
- **Error / Disabled:** semantic color plus text; do not rely on color alone.
|
||||||
|
- **Density:** dense but readable. Long business forms should use sections, sticky actions, and progressive disclosure.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
- **Style:** sticky headers, clear row dividers, horizontal overflow containment, and explicit wide/overview modes.
|
||||||
|
- **Header:** 12px semibold/demibold, neutral ink, sticky top.
|
||||||
|
- **Cells:** 14px body text, ellipsis only when the full value is available through tooltip, drawer detail, or export.
|
||||||
|
- **Actions:** fixed-width action column with consistent icon/button vocabulary.
|
||||||
|
|
||||||
|
### Drawers
|
||||||
|
- **Style:** use one drawer vocabulary for business editing and details. Headers include title, optional context tag, and a single close action.
|
||||||
|
- **Sizes:** normal, wide, xl, fullscreen. Use fullscreen only when the workflow genuinely needs table-scale comparison.
|
||||||
|
- **State:** every drawer form needs saving, success, error, disabled, and unsaved-change handling.
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- **Style:** left workflow navigation plus top breadcrumb and visited tags.
|
||||||
|
- **Active State:** active route should be obvious through background, text weight, and an icon or indicator, not a thick decorative stripe.
|
||||||
|
- **Collapsed State:** icon-only navigation must preserve tooltips and avoid layout width animations.
|
||||||
|
|
||||||
|
### Signature Component: Formal Documents
|
||||||
|
|
||||||
|
Formal document styling is allowed for sales orders, purchase orders, warehouse operations, quality inspection, and archived PDFs. It should look like a real operational document: grid lines, document number, signatures, archive status, and readable paper tone. Do not apply this paper treatment to ordinary dashboards or master-data maintenance.
|
||||||
|
|
||||||
|
## 6. Do's and Don'ts
|
||||||
|
|
||||||
|
### Do:
|
||||||
|
- **Do** keep routine ERP screens light, restrained, and table-first.
|
||||||
|
- **Do** use blue for primary actions, selected route/page state, focus, and traceability.
|
||||||
|
- **Do** standardize table, drawer, form, status badge, pagination, and action button behavior before styling individual pages.
|
||||||
|
- **Do** preserve dense information where users compare orders, batches, quantities, costs, and statuses.
|
||||||
|
- **Do** provide loading, empty, error, no-permission, disabled, saving, and archive-failed states for critical flows.
|
||||||
|
- **Do** keep formal paper styling scoped to business documents and PDF archive flows.
|
||||||
|
|
||||||
|
### Don't:
|
||||||
|
- **Don't** make screens look obviously AI-generated through generic card grids, decorative gradients, glass effects, or ornamental animation.
|
||||||
|
- **Don't** use `border-left` or `border-right` greater than 1px as colored accents on cards or callouts.
|
||||||
|
- **Don't** combine a 1px border with a large soft shadow on every card or button.
|
||||||
|
- **Don't** use 24px+ card radii on routine product UI.
|
||||||
|
- **Don't** let old dark industrial styling bleed into everyday ERP work screens.
|
||||||
|
- **Don't** show raw English enum codes in business UI.
|
||||||
|
- **Don't** hide long table values with ellipsis unless the complete value remains accessible.
|
||||||
33
PRODUCT.md
Normal file
33
PRODUCT.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
ForgeFlow ERP serves a roughly 50-person hardware manufacturing company. The primary users are managers, sales staff, planners, buyers, warehouse operators, workshop supervisors, production staff, finance users, equipment administrators, and system administrators. They use the system in office and shop-floor contexts to keep orders, materials, production, inventory, delivery, returns, and finance aligned.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
The product exists to make the full manufacturing loop visible and controllable: sales orders drive material requirements, procurement, warehouse batches, work orders, operation reporting, finished goods, delivery, returns, and cost reconciliation. Success means users can trace every important business fact back to orders, batches, operations, documents, and responsible roles without hunting through isolated screens.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Modern, clear, and easy to adopt. The interface should feel calm, precise, and work-focused: a mature ERP that helps people finish high-stakes daily tasks without visual noise. It can carry a light industrial character where it supports manufacturing context, but the default posture is clean operational clarity rather than heavy factory theming.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Do not make the product look obviously AI-generated. Avoid decorative SaaS landing-page tropes, oversized hero sections inside work screens, generic card grids, glassmorphism, gratuitous gradients, fake depth, excessive roundness, and ornamental motion. Avoid a default Element Plus-style admin surface that feels assembled from common templates. Avoid dark big-screen dashboard aesthetics for routine operational pages. Avoid paper-like document styling outside formal business documents and archived forms.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. Make the next operational action obvious.
|
||||||
|
2. Treat traceability as a first-class interface pattern.
|
||||||
|
3. Preserve density where users compare records, but keep hierarchy calm.
|
||||||
|
4. Use one consistent component vocabulary across modules.
|
||||||
|
5. Let document-like visuals appear only when the user is handling a formal business document.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Target WCAG AA for text contrast and interaction states. Support keyboard navigation for core workflows, clear focus states, readable Chinese text on ordinary office and workshop displays, reduced-motion preferences, and status communication that does not rely on color alone.
|
||||||
@ -50,4 +50,9 @@ npm run dev
|
|||||||
- 后端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/app/main.py`
|
- 后端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/app/main.py`
|
||||||
- 前端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend/src/main.js`
|
- 前端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend/src/main.js`
|
||||||
|
|
||||||
|
## 下一步建议
|
||||||
|
|
||||||
|
1. 你执行 DDL 建库建表。
|
||||||
|
2. 下一轮把 MySQL 连接信息给我。
|
||||||
|
3. 我继续把 SQLAlchemy 模型、真实 CRUD、登录鉴权、单据流转和报工小程序接入补齐。
|
||||||
|
|
||||||
|
|||||||
@ -10,3 +10,5 @@ MYSQL_PASSWORD=replace_me
|
|||||||
ALLOWED_ORIGINS=http://localhost:5173
|
ALLOWED_ORIGINS=http://localhost:5173
|
||||||
AUTH_SECRET_KEY=please-change-this-in-production
|
AUTH_SECRET_KEY=please-change-this-in-production
|
||||||
AUTH_TOKEN_EXPIRE_MINUTES=480
|
AUTH_TOKEN_EXPIRE_MINUTES=480
|
||||||
|
# 可选:多进程/多实例部署时配置固定登录 RSA 私钥,避免不同进程登录密钥不一致。
|
||||||
|
# AUTH_LOGIN_PRIVATE_KEY_PATH=/opt/ForgeFlow-ERP/backend/storage/auth_login_private_key.pem
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from app.db.session import get_db
|
|||||||
from app.models.org import User
|
from app.models.org import User
|
||||||
from app.schemas.database import (
|
from app.schemas.database import (
|
||||||
AuthChangePasswordRequest,
|
AuthChangePasswordRequest,
|
||||||
|
AuthLoginKeyResponse,
|
||||||
AuthLoginRequest,
|
AuthLoginRequest,
|
||||||
AuthResetPasswordRequest,
|
AuthResetPasswordRequest,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
@ -19,13 +20,20 @@ from app.services.auth import (
|
|||||||
require_system_admin,
|
require_system_admin,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
|
from app.services.login_crypto import decrypt_login_password, get_login_public_key
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login-key", response_model=AuthLoginKeyResponse)
|
||||||
|
def login_key() -> AuthLoginKeyResponse:
|
||||||
|
return AuthLoginKeyResponse(**get_login_public_key().__dict__)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AuthSession)
|
@router.post("/login", response_model=AuthSession)
|
||||||
def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthSession:
|
def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthSession:
|
||||||
return login_with_password(db, payload.username, payload.password)
|
password = decrypt_login_password(payload.password_ciphertext, payload.login_key_id)
|
||||||
|
return login_with_password(db, payload.username, password)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=LoginUser)
|
@router.get("/me", response_model=LoginUser)
|
||||||
|
|||||||
@ -90,7 +90,14 @@ class LoginUser(BaseModel):
|
|||||||
|
|
||||||
class AuthLoginRequest(BaseModel):
|
class AuthLoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password_ciphertext: str = Field(min_length=1)
|
||||||
|
login_key_id: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginKeyResponse(BaseModel):
|
||||||
|
key_id: str
|
||||||
|
public_key_pem: str
|
||||||
|
algorithm: str
|
||||||
|
|
||||||
|
|
||||||
class AuthChangePasswordRequest(BaseModel):
|
class AuthChangePasswordRequest(BaseModel):
|
||||||
|
|||||||
76
backend/app/services/login_crypto.py
Normal file
76
backend/app/services/login_crypto.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LoginPublicKey:
|
||||||
|
key_id: str
|
||||||
|
public_key_pem: str
|
||||||
|
algorithm: str = "RSA-OAEP-SHA256"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_env_pem(value: str) -> bytes:
|
||||||
|
return value.replace("\\n", "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _load_private_key():
|
||||||
|
private_key_path = os.getenv("AUTH_LOGIN_PRIVATE_KEY_PATH")
|
||||||
|
private_key_pem = os.getenv("AUTH_LOGIN_PRIVATE_KEY")
|
||||||
|
|
||||||
|
if private_key_path:
|
||||||
|
with open(private_key_path, "rb") as key_file:
|
||||||
|
key_data = key_file.read()
|
||||||
|
return serialization.load_pem_private_key(key_data, password=None)
|
||||||
|
|
||||||
|
if private_key_pem:
|
||||||
|
return serialization.load_pem_private_key(_normalize_env_pem(private_key_pem), password=None)
|
||||||
|
|
||||||
|
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_login_public_key() -> LoginPublicKey:
|
||||||
|
private_key = _load_private_key()
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
public_der = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
public_pem = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
).decode("utf-8")
|
||||||
|
key_id = hashlib.sha256(public_der).hexdigest()[:16]
|
||||||
|
return LoginPublicKey(key_id=key_id, public_key_pem=public_pem)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_login_password(password_ciphertext: str, login_key_id: str) -> str:
|
||||||
|
key_info = get_login_public_key()
|
||||||
|
if login_key_id != key_info.key_id:
|
||||||
|
raise HTTPException(status_code=400, detail="登录密钥已过期,请刷新后重试")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ciphertext = base64.b64decode(password_ciphertext.encode("utf-8"), validate=True)
|
||||||
|
plaintext = _load_private_key().decrypt(
|
||||||
|
ciphertext,
|
||||||
|
padding.OAEP(
|
||||||
|
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
label=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return plaintext.decode("utf-8")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="登录密码密文无效,请刷新后重试") from exc
|
||||||
41
backend/tests/test_auth_login_encryption.py
Normal file
41
backend/tests/test_auth_login_encryption.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
|
||||||
|
from app.services.login_crypto import decrypt_login_password, get_login_public_key
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginEncryptionTest(unittest.TestCase):
|
||||||
|
def test_decrypts_password_encrypted_with_public_key(self) -> None:
|
||||||
|
key_info = get_login_public_key()
|
||||||
|
public_key = serialization.load_pem_public_key(key_info.public_key_pem.encode("utf-8"))
|
||||||
|
ciphertext = public_key.encrypt(
|
||||||
|
"safe-password-123".encode("utf-8"),
|
||||||
|
padding.OAEP(
|
||||||
|
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
label=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
decrypted = decrypt_login_password(base64.b64encode(ciphertext).decode("utf-8"), key_info.key_id)
|
||||||
|
|
||||||
|
self.assertEqual(decrypted, "safe-password-123")
|
||||||
|
|
||||||
|
def test_rejects_unknown_login_key_id(self) -> None:
|
||||||
|
key_info = get_login_public_key()
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
decrypt_login_password("not-a-real-ciphertext", f"{key_info.key_id}-old")
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 400)
|
||||||
|
self.assertEqual(raised.exception.detail, "登录密钥已过期,请刷新后重试")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
# ForgeFlow ERP UI Optimization Design
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
ForgeFlow ERP has moved beyond the initial skeleton stage. The frontend now contains 33 Vue views, 29 Vue components, a large global stylesheet, and many business-critical flows across sales, procurement, warehouse, production, quality, finance, returns, permissions, and system extension.
|
||||||
|
|
||||||
|
The current UI has useful product foundations: workflow navigation, table controls, status badges, drawers, pagination, searchable selects, formal document flows, and PDF archive interactions. The main problem is not a lack of UI, but drift: multiple visual eras coexist, page-level styles override shared components, and high-value ERP workflows are hard to polish consistently.
|
||||||
|
|
||||||
|
## Confirmed Product Direction
|
||||||
|
|
||||||
|
The product register is `product`.
|
||||||
|
|
||||||
|
The desired brand personality is modern, clear, and easy to adopt. Routine ERP pages should feel calm, precise, and work-focused. They should avoid obvious AI-generated visual patterns, decorative SaaS tropes, heavy dark dashboard aesthetics, and paper styling outside formal business documents.
|
||||||
|
|
||||||
|
Accessibility defaults to WCAG AA, keyboard-friendly interaction, readable Chinese text, reduced-motion support, and non-color-only status communication.
|
||||||
|
|
||||||
|
## Current Findings
|
||||||
|
|
||||||
|
### Design Governance
|
||||||
|
|
||||||
|
- `PRODUCT.md` and `DESIGN.md` were missing and are now created.
|
||||||
|
- The project now has `.impeccable/design.json` for tooling metadata.
|
||||||
|
- Live mode is configured through `.impeccable/live/config.json`.
|
||||||
|
|
||||||
|
### Code And Styling Scale
|
||||||
|
|
||||||
|
- `frontend/src/styles/main.css` has about 17,322 lines.
|
||||||
|
- `frontend/src/views/InventoryLedgerView.vue` has about 7,119 lines.
|
||||||
|
- Other large files include `PurchaseOrderView.vue`, `ProductWorkflowDrawer.vue`, `SalesPlanningView.vue`, and `SystemPermissionView.vue`.
|
||||||
|
- The production build passes, but Vite reports a large JS chunk at about 797 kB and CSS at about 353 kB.
|
||||||
|
|
||||||
|
### Visual Drift
|
||||||
|
|
||||||
|
- The codebase contains an older dark industrial theme and a newer light admin theme.
|
||||||
|
- The light admin theme should become the default for routine ERP screens.
|
||||||
|
- Dark industrial styling should be limited to login or intentionally staged surfaces.
|
||||||
|
- Several pages still contain Element-like colors such as `#409eff`, `#606266`, and `#909399`; these are acceptable only if they are intentionally tokenized.
|
||||||
|
|
||||||
|
### Impeccable Detector Warnings
|
||||||
|
|
||||||
|
The detector currently reports 9 warnings:
|
||||||
|
|
||||||
|
- Side accent border in `frontend/src/styles/main.css`.
|
||||||
|
- Layout property animation using `transition: width` in `frontend/src/styles/main.css`.
|
||||||
|
- Side accent border in `frontend/src/views/PurchaseOrderView.vue`.
|
||||||
|
- Side accent border in `frontend/src/views/SalesPlanningView.vue`.
|
||||||
|
- Width/height transition in `frontend/src/components/OrgMindMap.vue`.
|
||||||
|
|
||||||
|
These warnings match the product goal: remove obvious AI-generated tells and reduce janky layout animation.
|
||||||
|
|
||||||
|
## Optimization Goals
|
||||||
|
|
||||||
|
1. Establish a single routine ERP visual language.
|
||||||
|
2. Make high-frequency workflows faster to scan and safer to operate.
|
||||||
|
3. Standardize tables, drawers, forms, buttons, status badges, pagination, empty states, and error states.
|
||||||
|
4. Reduce global CSS drift and page-level one-off styling.
|
||||||
|
5. Improve bundle health with route-level code splitting.
|
||||||
|
6. Keep formal paper/document visuals scoped to business documents and archive flows.
|
||||||
|
|
||||||
|
## Phase 1: Design System Baseline
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- `frontend/src/styles/main.css`
|
||||||
|
- `frontend/src/components/ActionButton.vue`
|
||||||
|
- `frontend/src/components/StatusBadge.vue`
|
||||||
|
- `frontend/src/components/PaginationBar.vue`
|
||||||
|
- `frontend/src/components/TableControls.vue`
|
||||||
|
- `frontend/src/components/FormDrawer.vue`
|
||||||
|
- `frontend/src/components/SmartDrawer.vue`
|
||||||
|
- `frontend/src/components/DrawerDataTable.vue`
|
||||||
|
- `frontend/src/components/GlobalSearchableSelect.vue`
|
||||||
|
- `frontend/src/components/StockLotTagSelect.vue`
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
- Create a small set of canonical CSS tokens for color, radius, shadow, focus, spacing, and z-index.
|
||||||
|
- Keep the light admin theme as the default authenticated app theme.
|
||||||
|
- Remove decorative gradients from routine buttons and cards.
|
||||||
|
- Replace 24px+ routine card radii with 12-14px values.
|
||||||
|
- Remove side accent borders greater than 1px except inside formal document grids where they have document semantics.
|
||||||
|
- Add `prefers-reduced-motion` handling for remaining motion.
|
||||||
|
- Replace width/height transition patterns with non-layout animation or no animation.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- `impeccable detect` warnings for side-tab and layout-transition are resolved or documented as intentional exceptions.
|
||||||
|
- Buttons, form fields, cards, chips, and status badges share tokenized colors and radii.
|
||||||
|
- `npm run build` passes.
|
||||||
|
|
||||||
|
## Phase 2: Core Workflow Shell
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- `frontend/src/App.vue`
|
||||||
|
- route navigation and tags view
|
||||||
|
- topbar broadcast and assistant entry
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
- Simplify topbar hierarchy: breadcrumb/current page first, secondary utilities second.
|
||||||
|
- Make visited tags easier to scan, close, and recover from.
|
||||||
|
- Keep assistant entry as a helper, not a competing primary action.
|
||||||
|
- Improve collapsed sidebar behavior and tooltips.
|
||||||
|
- Remove width animation from shell collapse where it causes layout work.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- Navigation remains clear at expanded, collapsed, and narrow widths.
|
||||||
|
- Current route is visible without relying on a thick side stripe.
|
||||||
|
- Keyboard focus states remain visible.
|
||||||
|
|
||||||
|
## Phase 3: Table And Data Interaction System
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- `TableControls`
|
||||||
|
- `DrawerDataTable`
|
||||||
|
- all high-frequency table pages
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
- Move table enhancement behavior toward declared component APIs rather than DOM patching where possible.
|
||||||
|
- Standardize table density, sticky headers, sort behavior, tooltips, wide mode, and action columns.
|
||||||
|
- Define empty states by page intent: no data, filtered out, no permission, sync pending, load failed.
|
||||||
|
- Define loading skeletons for data regions instead of content-area spinners.
|
||||||
|
- Ensure exported/PDF data never inherits frontend ellipsis behavior.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- Tables on master data, warehouse, purchase, sales, production, and finance pages share the same search, sort, pagination, tooltip, and empty-state vocabulary.
|
||||||
|
- Long business identifiers remain accessible.
|
||||||
|
- Action columns no longer resize unexpectedly.
|
||||||
|
|
||||||
|
## Phase 4: Inventory Ledger Refactor
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- `frontend/src/views/InventoryLedgerView.vue`
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
Split the large view into smaller units:
|
||||||
|
|
||||||
|
- warehouse selector and summary
|
||||||
|
- inbound/outbound operation launcher
|
||||||
|
- stock lot list
|
||||||
|
- transaction ledger drawer
|
||||||
|
- stocktake dialog integration
|
||||||
|
- document archive actions
|
||||||
|
- operation-specific form sections
|
||||||
|
- composables for warehouse state, filtering, and API calls
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- The main view becomes understandable without reading thousands of lines at once.
|
||||||
|
- Each warehouse operation form has consistent validation, loading, success, error, archive failed, and retry states.
|
||||||
|
- Existing warehouse business behavior remains unchanged.
|
||||||
|
|
||||||
|
## Phase 5: Formal Document System
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- sales order documents
|
||||||
|
- purchase order documents
|
||||||
|
- warehouse operation documents
|
||||||
|
- quality and production archive flows
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
- Keep formal document visuals scoped to real business documents.
|
||||||
|
- Extract document paper/grid/signature/archive action components.
|
||||||
|
- Avoid applying paper-like styling to dashboards and routine data tables.
|
||||||
|
- Align list archive state, preview, download, retry, and batch download interactions.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- Document entry and archive screens feel formal and traceable.
|
||||||
|
- Routine ERP pages remain light and product-like.
|
||||||
|
- Archive failure never silently disappears.
|
||||||
|
|
||||||
|
## Phase 6: Performance And Build Health
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- `frontend/src/router/index.js`
|
||||||
|
- large views and route imports
|
||||||
|
- global stylesheet ownership
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
- Convert route components to dynamic imports.
|
||||||
|
- Consider manual chunks only after route splitting.
|
||||||
|
- Move page-specific CSS out of `main.css` where practical.
|
||||||
|
- Keep global CSS for tokens, base layout, and shared components only.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- Initial JS chunk warning is reduced or eliminated.
|
||||||
|
- CSS is easier to attribute to shared vs page-specific concerns.
|
||||||
|
- Build continues to pass.
|
||||||
|
|
||||||
|
## First Implementation Slice
|
||||||
|
|
||||||
|
Start with Phase 1 plus a narrow Phase 4 audit:
|
||||||
|
|
||||||
|
1. Fix side accent border and layout transition warnings.
|
||||||
|
2. Normalize buttons, status badges, and table container tokens.
|
||||||
|
3. Audit `InventoryLedgerView.vue` for component extraction boundaries without changing business behavior yet.
|
||||||
|
4. Run `npm run build` and `impeccable detect`.
|
||||||
|
|
||||||
|
This creates a safer base before touching the largest workflow page.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cd frontend && npm run build`
|
||||||
|
- `node /Users/souplearn/.codex/skills/impeccable/scripts/detect.mjs --json frontend/src/App.vue frontend/src/styles/main.css frontend/src/views frontend/src/components`
|
||||||
|
- Manual browser checks at desktop and narrow widths when browser tooling is available.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This workspace is not currently a git repository, so this plan does not include commit steps. If the work later moves into a git worktree, commit after each phase.
|
||||||
@ -481,11 +481,11 @@ assert.match(
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
inventoryLedgerSource,
|
inventoryLedgerSource,
|
||||||
/source_doc_type: isProductionSurplusInbound\.value \? "WORK_ORDER" : null[\s\S]*source_doc_id: isProductionSurplusInbound\.value \? Number\(selectedGenericWorkOrder\.value\.work_order_id\) : null/
|
/source_doc_type: isProductionSurplusInbound\.value \? "PRODUCTION_LEDGER" : null[\s\S]*source_doc_id: isProductionSurplusInbound\.value \? Number\(selectedGenericWorkOrder\.value\.production_ledger_id \|\| selectedGenericWorkOrder\.value\.work_order_id\) : null/
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
inventoryLedgerSource,
|
inventoryLedgerSource,
|
||||||
/source_doc_type: isProductionScrapInbound\.value \|\| isReworkScrapInbound\.value \? "WORK_ORDER" : activeWarehouseType\.value === "SEMI" \? "PRODUCT_ROUTE_OPERATION" : null[\s\S]*source_doc_id: isProductionScrapInbound\.value \|\| isReworkScrapInbound\.value \? Number\(selectedGenericWorkOrder\.value\.work_order_id\) : activeWarehouseType\.value === "SEMI" \? Number\(selectedSourceDocId \|\| 0\) : null/
|
/source_doc_type: isProductionScrapInbound\.value \? "PRODUCTION_LEDGER" : isReworkScrapInbound\.value \? "WORK_ORDER" : activeWarehouseType\.value === "SEMI" \? "PRODUCT_ROUTE_OPERATION" : null[\s\S]*isProductionScrapInbound\.value[\s\S]*Number\(selectedGenericWorkOrder\.value\.production_ledger_id \|\| selectedGenericWorkOrder\.value\.work_order_id\)[\s\S]*isReworkScrapInbound\.value[\s\S]*Number\(selectedGenericWorkOrder\.value\.work_order_id\)/
|
||||||
);
|
);
|
||||||
assert.match(inventoryLedgerSource, /:key="`inventory-main-table-\$\{activeInventoryTab\}`"/);
|
assert.match(inventoryLedgerSource, /:key="`inventory-main-table-\$\{activeInventoryTab\}`"/);
|
||||||
assert.match(inventoryLedgerSource, /:key="`inventory-controls-\$\{activeInventoryTab\}`"/);
|
assert.match(inventoryLedgerSource, /:key="`inventory-controls-\$\{activeInventoryTab\}`"/);
|
||||||
|
|||||||
18
frontend/scripts/test-login-password-encryption.mjs
Normal file
18
frontend/scripts/test-login-password-encryption.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const source = readFileSync(path.join(root, "src/services/api.js"), "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /\/auth\/login-key/, "login should fetch the backend public key before submitting");
|
||||||
|
assert.match(source, /password_ciphertext/, "login payload should submit encrypted password ciphertext");
|
||||||
|
assert.match(source, /login_key_id/, "login payload should include the public key id used for encryption");
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/JSON\.stringify\(\{\s*username,\s*password\s*\}\)/,
|
||||||
|
"login must not serialize the plaintext password field"
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(source, /password:\s*password/, "login payload must not include plaintext password");
|
||||||
|
|
||||||
|
console.log("login password encryption checks passed");
|
||||||
@ -19,7 +19,7 @@ assert.match(
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/!isFinishedOutsourcingInbound && !isProductionScrapInbound && !isOutsourcingScrapInbound/,
|
/!isFinishedOutsourcingInbound && !isProductionScrapInbound && !isReworkScrapInbound && !isOutsourcingScrapInbound/,
|
||||||
"outsourcing scrap inbound should hide the manual lot number field"
|
"outsourcing scrap inbound should hide the manual lot number field"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const app = read("src/App.vue");
|
|||||||
const productionLedger = read("src/views/ProductionLedgerView.vue");
|
const productionLedger = read("src/views/ProductionLedgerView.vue");
|
||||||
const inventory = read("src/views/InventoryLedgerView.vue");
|
const inventory = read("src/views/InventoryLedgerView.vue");
|
||||||
|
|
||||||
assert.match(router, /ProductionLedgerView/, "router should import ProductionLedgerView");
|
assert.match(router, /component:\s*view\("ProductionLedgerView"\)/, "router should lazy load ProductionLedgerView");
|
||||||
assert.match(router, /name:\s*"production-ledger"/, "router should expose production-ledger route");
|
assert.match(router, /name:\s*"production-ledger"/, "router should expose production-ledger route");
|
||||||
assert.match(router, /path:\s*"\/work-order-ledger"[\s\S]*redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/, "old work-order-ledger route should redirect to production-ledger");
|
assert.match(router, /path:\s*"\/work-order-ledger"[\s\S]*redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/, "old work-order-ledger route should redirect to production-ledger");
|
||||||
assert.match(app, /生产台账/, "sidebar should show 生产台账");
|
assert.match(app, /生产台账/, "sidebar should show 生产台账");
|
||||||
|
|||||||
@ -15,12 +15,12 @@ const inventoryView = readSource("src/views/InventoryLedgerView.vue");
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
router,
|
router,
|
||||||
/import\s+ProductionLedgerView\s+from\s+"..\/views\/ProductionLedgerView\.vue";/,
|
/component:\s*view\("ProductionLedgerView"\)/,
|
||||||
"router should import ProductionLedgerView"
|
"router should lazy load ProductionLedgerView"
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
router,
|
router,
|
||||||
/path:\s*"\/production-ledger"[\s\S]*?name:\s*"production-ledger"[\s\S]*?component:\s*ProductionLedgerView[\s\S]*?permission:\s*"MENU_WORK_ORDER"/,
|
/path:\s*"\/production-ledger"[\s\S]*?name:\s*"production-ledger"[\s\S]*?component:\s*view\("ProductionLedgerView"\)[\s\S]*?permission:\s*"MENU_WORK_ORDER"/,
|
||||||
"router should define production-ledger route with MENU_WORK_ORDER permission"
|
"router should define production-ledger route with MENU_WORK_ORDER permission"
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@ -31,7 +31,7 @@ assert.match(
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/ref="salesOrderPickerRef"\s+class="order-tag-picker"\s+@pointerdown\.stop\s+@mousedown\.stop\s+@click\.stop/,
|
/ref="salesOrderPickerRef"[\s\S]*class="[^"]*\border-tag-picker\b[^"]*"[\s\S]*@pointerdown\.stop[\s\S]*@mousedown\.stop[\s\S]*@click\.stop/,
|
||||||
"sales order picker should keep internal clicks from bubbling into unrelated close handlers"
|
"sales order picker should keep internal clicks from bubbling into unrelated close handlers"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,11 @@ assert.doesNotMatch(source, /YL-NB|buildDefaultLotNo/, "purchase receipt UI must
|
|||||||
assert.doesNotMatch(source, /送货单号|supplier_delivery_no/, "purchase receipt UI should not keep the duplicate supplier delivery number field");
|
assert.doesNotMatch(source, /送货单号|supplier_delivery_no/, "purchase receipt UI should not keep the duplicate supplier delivery number field");
|
||||||
assert.match(source, /后端自动生成库存批次号|系统自动生成库存批次号/, "purchase receipt UI should explain that inventory lot numbers are generated by the backend");
|
assert.match(source, /后端自动生成库存批次号|系统自动生成库存批次号/, "purchase receipt UI should explain that inventory lot numbers are generated by the backend");
|
||||||
assert.match(source, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos");
|
assert.match(source, /uploadResource/, "purchase receipt UI should upload logistics auxiliary photos");
|
||||||
assert.match(source, /selectedPurchaseTargetType\s*===\s*["']RAW["'][\s\S]*运单号[\s\S]*辅助照片/, "raw purchase receipt UI should show waybill and auxiliary photo fields");
|
assert.match(
|
||||||
|
source,
|
||||||
|
/if\s*\(\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\)\s*\{[\s\S]*key:\s*["']waybill_no["'][\s\S]*label:\s*["']运单号["'][\s\S]*key:\s*["']order_photo["'][\s\S]*label:\s*["']辅助照片["']/,
|
||||||
|
"raw purchase receipt UI should show waybill and auxiliary photo fields"
|
||||||
|
);
|
||||||
assert.match(source, /validateLogisticsForm\s*\(\s*\)/, "raw purchase receipt submit should validate logistics information before posting");
|
assert.match(source, /validateLogisticsForm\s*\(\s*\)/, "raw purchase receipt submit should validate logistics information before posting");
|
||||||
assert.match(source, /waybill_no:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.waybill_no/, "purchase receipt payload should include raw waybill number");
|
assert.match(source, /waybill_no:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.waybill_no/, "purchase receipt payload should include raw waybill number");
|
||||||
assert.match(source, /order_photo_url:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.order_photo_url/, "purchase receipt payload should include raw auxiliary photo URL");
|
assert.match(source, /order_photo_url:\s*selectedPurchaseTargetType\.value\s*===\s*["']RAW["']\s*\?\s*form\.order_photo_url/, "purchase receipt payload should include raw auxiliary photo URL");
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const requiredInventorySnippets = [
|
|||||||
"返工废料入库",
|
"返工废料入库",
|
||||||
"reworkScrapInbound",
|
"reworkScrapInbound",
|
||||||
"isReworkScrapInbound",
|
"isReworkScrapInbound",
|
||||||
"source_doc_type: isProductionScrapInbound.value || isReworkScrapInbound.value ? \"WORK_ORDER\"",
|
"source_doc_type: isProductionScrapInbound.value ? \"PRODUCTION_LEDGER\" : isReworkScrapInbound.value ? \"WORK_ORDER\"",
|
||||||
"inbound_qty: inboundQty"
|
"inbound_qty: inboundQty"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
29
frontend/scripts/test-router-lazy-loading.mjs
Normal file
29
frontend/scripts/test-router-lazy-loading.mjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const routerSource = readFileSync(new URL("../src/router/index.js", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
assert.doesNotMatch(
|
||||||
|
routerSource,
|
||||||
|
/import\s+\w+View\s+from\s+"..\/views\/[^"]+\.vue";/,
|
||||||
|
"router should not statically import view components into the initial bundle"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
routerSource,
|
||||||
|
/import\.meta\.glob\("\.\.\/views\/\*\.vue"\)/,
|
||||||
|
"router should register lazy view modules with Vite import.meta.glob"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const viewName of [
|
||||||
|
"LoginView",
|
||||||
|
"DashboardView",
|
||||||
|
"InventoryLedgerView",
|
||||||
|
"PurchaseOrderView",
|
||||||
|
"ProductionLedgerView",
|
||||||
|
"SystemPermissionView"
|
||||||
|
]) {
|
||||||
|
assert.match(routerSource, new RegExp(`component:\\s*view\\("${viewName}"\\)`), `${viewName} route should use lazy view loader`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("router lazy loading static checks passed");
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const appVuePath = resolve("frontend/src/App.vue");
|
const source = readFileSync(new URL("../src/App.vue", import.meta.url), "utf8");
|
||||||
const source = readFileSync(appVuePath, "utf8");
|
|
||||||
|
|
||||||
const navStart = source.indexOf("const navStages = [");
|
const navStart = source.indexOf("const navStages = [");
|
||||||
const navEnd = source.indexOf("\n];", navStart);
|
const navEnd = source.indexOf("\n];", navStart);
|
||||||
|
|||||||
@ -139,13 +139,19 @@ const styleSource = readFileSync(
|
|||||||
fileURLToPath(new URL("../src/styles/main.css", import.meta.url)),
|
fileURLToPath(new URL("../src/styles/main.css", import.meta.url)),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
|
const stocktakeDiffModalStart = styleSource.indexOf(".stocktake-diff-modal {");
|
||||||
|
const stocktakeDiffModalEnd = styleSource.indexOf("\n}", stocktakeDiffModalStart);
|
||||||
|
assert.notEqual(stocktakeDiffModalStart, -1);
|
||||||
|
assert.notEqual(stocktakeDiffModalEnd, -1);
|
||||||
|
const stocktakeDiffModalBlock = styleSource.slice(stocktakeDiffModalStart, stocktakeDiffModalEnd + 2);
|
||||||
|
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-diff-table-v2[\s\S]*margin: 14px 18px 0;/);
|
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-diff-table-v2[\s\S]*margin: 14px 18px 0;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*min-height: 72px;/);
|
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*min-height: 72px;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*box-sizing: border-box;/);
|
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*box-sizing: border-box;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*z-index: 3;/);
|
assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*z-index: 3;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*height: min\(820px, 100%\);/);
|
assert.match(stocktakeDiffModalBlock, /height: min\(820px, 100%\);/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*max-height: 100%;/);
|
assert.match(stocktakeDiffModalBlock, /max-height: 100%;/);
|
||||||
assert.doesNotMatch(styleSource, /\.stocktake-diff-modal[\s\S]*calc\(100vh - 64px\)/);
|
assert.doesNotMatch(stocktakeDiffModalBlock, /calc\(100vh - 64px\)/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*justify-content: flex-end;/);
|
assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*justify-content: flex-end;/);
|
||||||
assert.match(styleSource, /\.stocktake-confirm-mask[\s\S]*z-index: 4;/);
|
assert.match(styleSource, /\.stocktake-confirm-mask[\s\S]*z-index: 4;/);
|
||||||
assert.match(styleSource, /\.stocktake-diff-cell,[\s\S]*overflow-wrap: anywhere;/);
|
assert.match(styleSource, /\.stocktake-diff-cell,[\s\S]*overflow-wrap: anywhere;/);
|
||||||
|
|||||||
@ -18,8 +18,8 @@ assert.match(
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
routerSource,
|
routerSource,
|
||||||
/import SystemPermissionView from "\.\.\/views\/SystemPermissionView\.vue";/,
|
/component:\s*view\("SystemPermissionView"\)/,
|
||||||
"router should import SystemPermissionView"
|
"router should lazy load SystemPermissionView"
|
||||||
);
|
);
|
||||||
assert.match(routerSource, /path:\s*"\/system-management"[\s\S]*redirect:/, "router should add /system-management redirect");
|
assert.match(routerSource, /path:\s*"\/system-management"[\s\S]*redirect:/, "router should add /system-management redirect");
|
||||||
assert.match(routerSource, /path:\s*"\/system-permissions"/, "router should add /system-permissions route");
|
assert.match(routerSource, /path:\s*"\/system-permissions"/, "router should add /system-permissions route");
|
||||||
@ -203,7 +203,7 @@ assert.doesNotMatch(
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
systemPermissionSource,
|
systemPermissionSource,
|
||||||
/function fitMindMapToScreen\(\)[\s\S]*mindMapFitRequestKey\.value \+= 1/,
|
/async function requestMindMapFitToScreen[\s\S]*mindMapFitRequestKey\.value \+= 1[\s\S]*function fitMindMapToScreen\(\)\s*\{\s*requestMindMapFitToScreen\(\{ showToast: true, closeMenu: true \}\);/m,
|
||||||
"one-screen fit action should request a viewport fit explicitly"
|
"one-screen fit action should request a viewport fit explicitly"
|
||||||
);
|
);
|
||||||
assert.match(systemPermissionSource, /const updatePayload\s*=/, "org edit must build an update-only payload");
|
assert.match(systemPermissionSource, /const updatePayload\s*=/, "org edit must build an update-only payload");
|
||||||
|
|||||||
40
frontend/scripts/test-ui-antipattern-guard.mjs
Normal file
40
frontend/scripts/test-ui-antipattern-guard.mjs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function read(path) {
|
||||||
|
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleBlock(source, selector) {
|
||||||
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const match = source.match(new RegExp(`${escaped}\\s*\\{[\\s\\S]*?\\n\\}`, "m"));
|
||||||
|
assert.ok(match, `Expected to find CSS block for ${selector}`);
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoDeclaration(block, property) {
|
||||||
|
assert.ok(!new RegExp(`\\b${property}\\s*:`, "m").test(block), `Unexpected ${property} declaration in:\n${block}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainCss = read("src/styles/main.css");
|
||||||
|
const purchaseOrderView = read("src/views/PurchaseOrderView.vue");
|
||||||
|
const salesPlanningView = read("src/views/SalesPlanningView.vue");
|
||||||
|
const orgMindMap = read("src/components/OrgMindMap.vue");
|
||||||
|
|
||||||
|
assertNoDeclaration(ruleBlock(mainCss, ".workflow-drawer"), "border-left");
|
||||||
|
assertNoDeclaration(ruleBlock(mainCss, "body .login-console-frame"), "border-left");
|
||||||
|
assertNoDeclaration(ruleBlock(purchaseOrderView, ".section-title"), "border-left");
|
||||||
|
assertNoDeclaration(ruleBlock(salesPlanningView, ".section-title"), "border-left");
|
||||||
|
assertNoDeclaration(ruleBlock(purchaseOrderView, ".purchase-decision-section"), "border-right");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
!/transition:[^;]*(?:stroke-width|\br\s+[0-9.]+s)/.test(mainCss),
|
||||||
|
"SVG DAG transitions should not animate stroke-width or radius-like r properties"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
!/transition:[^;]*(?:\bwidth\b|\bheight\b)/.test(orgMindMap),
|
||||||
|
"Org mind map canvas should not animate width or height"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("ui antipattern guard ok");
|
||||||
@ -484,7 +484,6 @@ defineExpose({
|
|||||||
|
|
||||||
.org-mind-map-canvas {
|
.org-mind-map-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
transition: width 0.18s ease, height 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-mind-map-connector {
|
.org-mind-map-connector {
|
||||||
|
|||||||
@ -1,30 +1,18 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
import DashboardView from "../views/DashboardView.vue";
|
|
||||||
import SalesPlanningView from "../views/SalesPlanningView.vue";
|
|
||||||
import EmployeeManagementView from "../views/EmployeeManagementView.vue";
|
|
||||||
import ProductManagementView from "../views/ProductManagementView.vue";
|
|
||||||
import BomManagementView from "../views/BomManagementView.vue";
|
|
||||||
import ProcessRouteManagementView from "../views/ProcessRouteManagementView.vue";
|
|
||||||
import LoginView from "../views/LoginView.vue";
|
|
||||||
import SupplierManagementView from "../views/SupplierManagementView.vue";
|
|
||||||
import PurchaseOrderView from "../views/PurchaseOrderView.vue";
|
|
||||||
import PurchaseReceiptView from "../views/PurchaseReceiptView.vue";
|
|
||||||
import InventoryLedgerView from "../views/InventoryLedgerView.vue";
|
|
||||||
import ProductionLedgerView from "../views/ProductionLedgerView.vue";
|
|
||||||
import OperationReportView from "../views/OperationReportView.vue";
|
|
||||||
import DeliveryManagementView from "../views/DeliveryManagementView.vue";
|
|
||||||
import EquipmentManagementView from "../views/EquipmentManagementView.vue";
|
|
||||||
import QualityInspectionView from "../views/QualityInspectionView.vue";
|
|
||||||
import FinanceReportView from "../views/FinanceReportView.vue";
|
|
||||||
import ProductSpecLiteView from "../views/ProductSpecLiteView.vue";
|
|
||||||
import SupplierMaterialLiteView from "../views/SupplierMaterialLiteView.vue";
|
|
||||||
import SystemPermissionView from "../views/SystemPermissionView.vue";
|
|
||||||
import SystemExtensionView from "../views/SystemExtensionView.vue";
|
|
||||||
import MasterDataImportView from "../views/MasterDataImportView.vue";
|
|
||||||
import { isAuthenticated, readSession } from "../services/api";
|
import { isAuthenticated, readSession } from "../services/api";
|
||||||
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
|
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
|
||||||
|
|
||||||
|
const viewModules = import.meta.glob("../views/*.vue");
|
||||||
|
|
||||||
|
function view(name) {
|
||||||
|
const loader = viewModules[`../views/${name}.vue`];
|
||||||
|
if (!loader) {
|
||||||
|
throw new Error(`Missing route view: ${name}`);
|
||||||
|
}
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
|
||||||
const masterDataPermissionOrder = [
|
const masterDataPermissionOrder = [
|
||||||
{
|
{
|
||||||
routeName: "supplier-management",
|
routeName: "supplier-management",
|
||||||
@ -224,13 +212,13 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
name: "login",
|
name: "login",
|
||||||
component: LoginView,
|
component: view("LoginView"),
|
||||||
meta: { title: "工业风登录", layout: "auth" }
|
meta: { title: "工业风登录", layout: "auth" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
component: DashboardView,
|
component: view("DashboardView"),
|
||||||
meta: { title: "原材料批次生命周期追踪", requiresAuth: true, permission: "MENU_DASHBOARD" }
|
meta: { title: "原材料批次生命周期追踪", requiresAuth: true, permission: "MENU_DASHBOARD" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -243,13 +231,13 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/customer-management",
|
path: "/customer-management",
|
||||||
name: "customer-management",
|
name: "customer-management",
|
||||||
component: SalesPlanningView,
|
component: view("SalesPlanningView"),
|
||||||
meta: { title: "客户名录", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
meta: { title: "客户名录", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/order-list",
|
path: "/order-list",
|
||||||
name: "order-list",
|
name: "order-list",
|
||||||
component: SalesPlanningView,
|
component: view("SalesPlanningView"),
|
||||||
meta: { title: "销售订单列表", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
meta: { title: "销售订单列表", requiresAuth: true, permission: "MENU_SALES_PLANNING" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -312,7 +300,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/supplier-material-lite",
|
path: "/supplier-material-lite",
|
||||||
name: "supplier-material-lite",
|
name: "supplier-material-lite",
|
||||||
component: SupplierMaterialLiteView,
|
component: view("SupplierMaterialLiteView"),
|
||||||
meta: {
|
meta: {
|
||||||
title: "原材料名录",
|
title: "原材料名录",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@ -322,7 +310,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/product-spec-lite",
|
path: "/product-spec-lite",
|
||||||
name: "product-spec-lite",
|
name: "product-spec-lite",
|
||||||
component: ProductSpecLiteView,
|
component: view("ProductSpecLiteView"),
|
||||||
meta: {
|
meta: {
|
||||||
title: "产品需规清单",
|
title: "产品需规清单",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@ -332,31 +320,31 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/supplier-management",
|
path: "/supplier-management",
|
||||||
name: "supplier-management",
|
name: "supplier-management",
|
||||||
component: SupplierManagementView,
|
component: view("SupplierManagementView"),
|
||||||
meta: { title: "供应商名录", requiresAuth: true, permission: "MENU_SUPPLIER_MANAGEMENT" }
|
meta: { title: "供应商名录", requiresAuth: true, permission: "MENU_SUPPLIER_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/purchase-orders",
|
path: "/purchase-orders",
|
||||||
name: "purchase-orders",
|
name: "purchase-orders",
|
||||||
component: PurchaseOrderView,
|
component: view("PurchaseOrderView"),
|
||||||
meta: { title: "采购订单", requiresAuth: true, permission: "MENU_PURCHASE_ORDER" }
|
meta: { title: "采购订单", requiresAuth: true, permission: "MENU_PURCHASE_ORDER" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/purchase-receipts",
|
path: "/purchase-receipts",
|
||||||
name: "purchase-receipts",
|
name: "purchase-receipts",
|
||||||
component: PurchaseReceiptView,
|
component: view("PurchaseReceiptView"),
|
||||||
meta: { title: "到货入库", requiresAuth: true, permission: "MENU_PURCHASE_RECEIPT" }
|
meta: { title: "到货入库", requiresAuth: true, permission: "MENU_PURCHASE_RECEIPT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/inventory-ledger",
|
path: "/inventory-ledger",
|
||||||
name: "inventory-ledger",
|
name: "inventory-ledger",
|
||||||
component: InventoryLedgerView,
|
component: view("InventoryLedgerView"),
|
||||||
meta: { title: "原材料库存台账与批次", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" }
|
meta: { title: "原材料库存台账与批次", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/production-ledger",
|
path: "/production-ledger",
|
||||||
name: "production-ledger",
|
name: "production-ledger",
|
||||||
component: ProductionLedgerView,
|
component: view("ProductionLedgerView"),
|
||||||
meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" }
|
meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -368,7 +356,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/operation-reports",
|
path: "/operation-reports",
|
||||||
name: "operation-reports",
|
name: "operation-reports",
|
||||||
component: OperationReportView,
|
component: view("OperationReportView"),
|
||||||
meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" }
|
meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -380,25 +368,25 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/delivery-management",
|
path: "/delivery-management",
|
||||||
name: "delivery-management",
|
name: "delivery-management",
|
||||||
component: DeliveryManagementView,
|
component: view("DeliveryManagementView"),
|
||||||
meta: { title: "发货台账", requiresAuth: true, permission: "MENU_DELIVERY_MANAGEMENT" }
|
meta: { title: "发货台账", requiresAuth: true, permission: "MENU_DELIVERY_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/equipment-management",
|
path: "/equipment-management",
|
||||||
name: "equipment-management",
|
name: "equipment-management",
|
||||||
component: EquipmentManagementView,
|
component: view("EquipmentManagementView"),
|
||||||
meta: { title: "设备管理", requiresAuth: true, permission: "MENU_EQUIPMENT_MANAGEMENT" }
|
meta: { title: "设备管理", requiresAuth: true, permission: "MENU_EQUIPMENT_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/quality-inspection",
|
path: "/quality-inspection",
|
||||||
name: "quality-inspection",
|
name: "quality-inspection",
|
||||||
component: QualityInspectionView,
|
component: view("QualityInspectionView"),
|
||||||
meta: { title: "质量检验", requiresAuth: true, permission: "MENU_QUALITY_INSPECTION" }
|
meta: { title: "质量检验", requiresAuth: true, permission: "MENU_QUALITY_INSPECTION" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/finance-reports",
|
path: "/finance-reports",
|
||||||
name: "finance-reports",
|
name: "finance-reports",
|
||||||
component: FinanceReportView,
|
component: view("FinanceReportView"),
|
||||||
meta: { title: "冲压产品材料成本核算", requiresAuth: true, permission: "MENU_FINANCE_REPORT" }
|
meta: { title: "冲压产品材料成本核算", requiresAuth: true, permission: "MENU_FINANCE_REPORT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -416,25 +404,25 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/bom-management",
|
path: "/bom-management",
|
||||||
name: "bom-management",
|
name: "bom-management",
|
||||||
component: BomManagementView,
|
component: view("BomManagementView"),
|
||||||
meta: { title: "BOM管理", requiresAuth: true, permission: "MENU_BOM_MANAGEMENT" }
|
meta: { title: "BOM管理", requiresAuth: true, permission: "MENU_BOM_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/process-route-management",
|
path: "/process-route-management",
|
||||||
name: "process-route-management",
|
name: "process-route-management",
|
||||||
component: ProcessRouteManagementView,
|
component: view("ProcessRouteManagementView"),
|
||||||
meta: { title: "工艺路线管理", requiresAuth: true, permission: "MENU_ROUTE_MANAGEMENT" }
|
meta: { title: "工艺路线管理", requiresAuth: true, permission: "MENU_ROUTE_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/employee-management",
|
path: "/employee-management",
|
||||||
name: "employee-management",
|
name: "employee-management",
|
||||||
component: EmployeeManagementView,
|
component: view("EmployeeManagementView"),
|
||||||
meta: { title: "员工管理", requiresAuth: true, permission: "MENU_EMPLOYEE_MANAGEMENT" }
|
meta: { title: "员工管理", requiresAuth: true, permission: "MENU_EMPLOYEE_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/master-data-import",
|
path: "/master-data-import",
|
||||||
name: "master-data-import",
|
name: "master-data-import",
|
||||||
component: MasterDataImportView,
|
component: view("MasterDataImportView"),
|
||||||
meta: {
|
meta: {
|
||||||
title: "基础资料导入导出",
|
title: "基础资料导入导出",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@ -444,7 +432,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/product-management",
|
path: "/product-management",
|
||||||
name: "product-management",
|
name: "product-management",
|
||||||
component: ProductManagementView,
|
component: view("ProductManagementView"),
|
||||||
meta: { title: "产品管理", requiresAuth: true, permission: "MENU_PRODUCT_MANAGEMENT" }
|
meta: { title: "产品管理", requiresAuth: true, permission: "MENU_PRODUCT_MANAGEMENT" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -460,13 +448,13 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/system-permissions",
|
path: "/system-permissions",
|
||||||
name: "system-permissions",
|
name: "system-permissions",
|
||||||
component: SystemPermissionView,
|
component: view("SystemPermissionView"),
|
||||||
meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" }
|
meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/system-extension",
|
path: "/system-extension",
|
||||||
name: "system-extension",
|
name: "system-extension",
|
||||||
component: SystemExtensionView,
|
component: view("SystemExtensionView"),
|
||||||
meta: { title: "系统拓展", requiresAuth: true, permission: "MENU_SYSTEM_EXTENSION" }
|
meta: { title: "系统拓展", requiresAuth: true, permission: "MENU_SYSTEM_EXTENSION" }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -245,10 +245,64 @@ export function isAuthenticated() {
|
|||||||
return Boolean(readSession());
|
return Boolean(readSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pemToArrayBuffer(pem) {
|
||||||
|
const base64Text = pem
|
||||||
|
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
|
||||||
|
.replace(/-----END PUBLIC KEY-----/g, "")
|
||||||
|
.replace(/\s/g, "");
|
||||||
|
const binary = window.atob(base64Text);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[index]);
|
||||||
|
}
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptLoginPassword(password, publicKeyPem) {
|
||||||
|
if (!window.crypto?.subtle) {
|
||||||
|
throw new Error("当前访问方式不支持安全加密登录,请使用 HTTPS 或 localhost 地址访问系统");
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = await window.crypto.subtle.importKey(
|
||||||
|
"spki",
|
||||||
|
pemToArrayBuffer(publicKeyPem),
|
||||||
|
{
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
hash: "SHA-256"
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
["encrypt"]
|
||||||
|
);
|
||||||
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: "RSA-OAEP" },
|
||||||
|
publicKey,
|
||||||
|
new TextEncoder().encode(password)
|
||||||
|
);
|
||||||
|
return arrayBufferToBase64(ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
export async function login(username, password) {
|
export async function login(username, password) {
|
||||||
|
const loginKey = await requestJson("/auth/login-key", {
|
||||||
|
method: "GET",
|
||||||
|
skipAuthRedirect: true
|
||||||
|
});
|
||||||
|
const passwordCiphertext = await encryptLoginPassword(password, loginKey.public_key_pem);
|
||||||
return requestJson("/auth/login", {
|
return requestJson("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password_ciphertext: passwordCiphertext,
|
||||||
|
login_key_id: loginKey.key_id
|
||||||
|
}),
|
||||||
skipAuthRedirect: true
|
skipAuthRedirect: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2556,7 +2556,7 @@ a {
|
|||||||
stroke-linejoin: bevel;
|
stroke-linejoin: bevel;
|
||||||
filter: drop-shadow(0 0 7px rgba(79, 180, 255, 0.34));
|
filter: drop-shadow(0 0 7px rgba(79, 180, 255, 0.34));
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
transition: stroke 0.14s ease, stroke-width 0.14s ease, opacity 0.14s ease, filter 0.14s ease;
|
transition: stroke 0.14s ease, opacity 0.14s ease, filter 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dag-edge-highlighted {
|
.dag-edge-highlighted {
|
||||||
@ -2605,7 +2605,7 @@ a {
|
|||||||
stroke: rgba(6, 21, 34, 0.96);
|
stroke: rgba(6, 21, 34, 0.96);
|
||||||
stroke-width: 1.6;
|
stroke-width: 1.6;
|
||||||
filter: drop-shadow(0 0 8px rgba(79, 180, 255, 0.5));
|
filter: drop-shadow(0 0 8px rgba(79, 180, 255, 0.5));
|
||||||
transition: r 0.14s ease, opacity 0.14s ease, filter 0.14s ease, stroke-width 0.14s ease;
|
transition: opacity 0.14s ease, filter 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dag-edge-terminal-warning {
|
.dag-edge-terminal-warning {
|
||||||
@ -2621,7 +2621,7 @@ a {
|
|||||||
stroke: rgba(144, 215, 255, 0.96);
|
stroke: rgba(144, 215, 255, 0.96);
|
||||||
stroke-width: 2.4;
|
stroke-width: 2.4;
|
||||||
filter: drop-shadow(0 0 8px rgba(79, 180, 255, 0.5));
|
filter: drop-shadow(0 0 8px rgba(79, 180, 255, 0.5));
|
||||||
transition: r 0.14s ease, opacity 0.14s ease, filter 0.14s ease, stroke-width 0.14s ease;
|
transition: opacity 0.14s ease, filter 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dag-edge-start-warning {
|
.dag-edge-start-warning {
|
||||||
@ -3825,7 +3825,7 @@ a {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at top right, rgba(79, 180, 255, 0.14), transparent 24%),
|
radial-gradient(circle at top right, rgba(79, 180, 255, 0.14), transparent 24%),
|
||||||
linear-gradient(180deg, rgba(9, 18, 29, 0.98), rgba(5, 11, 18, 0.98));
|
linear-gradient(180deg, rgba(9, 18, 29, 0.98), rgba(5, 11, 18, 0.98));
|
||||||
border-left: 1px solid rgba(79, 180, 255, 0.2);
|
border: 1px solid rgba(79, 180, 255, 0.2);
|
||||||
border-radius: 28px 0 0 28px;
|
border-radius: 28px 0 0 28px;
|
||||||
box-shadow: -24px 0 70px rgba(0, 0, 0, 0.32);
|
box-shadow: -24px 0 70px rgba(0, 0, 0, 0.32);
|
||||||
}
|
}
|
||||||
@ -15060,7 +15060,7 @@ body .login-console-frame {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: 20px 20px 22px 24px;
|
padding: 20px 20px 22px 24px;
|
||||||
border-left: 2px solid rgba(56, 189, 248, 0.48);
|
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||||
border-radius: 34px;
|
border-radius: 34px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(56, 189, 248, 0.12), transparent 22%),
|
linear-gradient(90deg, rgba(56, 189, 248, 0.12), transparent 22%),
|
||||||
|
|||||||
@ -411,6 +411,7 @@
|
|||||||
:class="{ 'is-uploaded': genericForm.order_photo_url, 'is-busy': logisticsPhotoUploading }"
|
:class="{ 'is-uploaded': genericForm.order_photo_url, 'is-busy': logisticsPhotoUploading }"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
class="modern-upload-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
:disabled="logisticsPhotoUploading"
|
:disabled="logisticsPhotoUploading"
|
||||||
|
|||||||
@ -160,6 +160,7 @@ async function handleLogin() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await login(form.username, form.password);
|
const user = await login(form.username, form.password);
|
||||||
|
form.password = "";
|
||||||
saveSession(user);
|
saveSession(user);
|
||||||
message.value = `欢迎回来,${user.display_name}`;
|
message.value = `欢迎回来,${user.display_name}`;
|
||||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
||||||
|
|||||||
@ -1444,7 +1444,7 @@ function renderPurchaseContractHtml(contractOrders) {
|
|||||||
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
||||||
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
||||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
||||||
.section-title { margin: 22px 0 10px; padding-left: 10px; border-left: 4px solid #2563eb; font-weight: 800; font-size: 16px; }
|
.section-title { margin: 22px 0 10px; padding: 6px 10px; border: 1px solid #bfdbfe; border-radius: 4px; background: #eff6ff; font-weight: 800; font-size: 16px; }
|
||||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
||||||
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
||||||
th { background: #eaf2ff; text-align: center; }
|
th { background: #eaf2ff; text-align: center; }
|
||||||
@ -2281,7 +2281,7 @@ onUnmounted(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-right: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88));
|
box-shadow: inset -1px 0 0 var(--document-paper-grid, rgba(20, 14, 6, 0.88));
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -932,7 +932,7 @@ function renderContractHtml(orders) {
|
|||||||
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
h1 { margin: 0 0 10px; text-align: center; letter-spacing: 10px; font-size: 28px; }
|
||||||
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
.contract-no { text-align: right; color: #475569; margin-bottom: 22px; }
|
||||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 18px; line-height: 1.8; }
|
||||||
.section-title { margin: 22px 0 10px; padding-left: 10px; border-left: 4px solid #2563eb; font-weight: 800; font-size: 16px; }
|
.section-title { margin: 22px 0 10px; padding: 6px 10px; border: 1px solid #bfdbfe; border-radius: 4px; background: #eff6ff; font-weight: 800; font-size: 16px; }
|
||||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 12px; }
|
||||||
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
th, td { border: 1px solid #94a3b8; padding: 7px 6px; vertical-align: middle; }
|
||||||
th { background: #eaf2ff; text-align: center; }
|
th { background: #eaf2ff; text-align: center; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user