first commit

This commit is contained in:
LinZhongyan 2026-03-25 01:54:12 +08:00
commit 41a2ebb997
51 changed files with 13488 additions and 0 deletions

31
.eslintrc.cjs Normal file
View File

@ -0,0 +1,31 @@
module.exports = {
root: true,
env: {
browser: true,
es2020: true,
node: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2020,
sourceType: 'module',
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attributes-order': 'off',
'vue/html-self-closing': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
},
ignorePatterns: ['dist/', 'node_modules/', 'api/data/'],
}

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# 订单跟进系统Vue3 + Express
包含:登录/注册、按角色权限控制、订单 CRUD、订单进度时间线、仪表盘统计ECharts
## 启动
1. 安装依赖
```bash
npm install
```
2. 启动前后端(前端 Vite + 后端 Express
```bash
npm run dev
```
- 前端默认:`http://localhost:5173`
- 后端默认:`http://localhost:3001`
- 前端通过 `vite.config.ts``/api/*` 代理到后端。
## 示例账号
- `sales / sales123`
- `purchase / purchase123`
- `manager / manager123`
- `admin / admin123`
## 数据存储
后端使用本地 JSON 文件持久化(自动初始化种子数据):`api/data/db.json`。
## 环境变量(可选)
- `JWT_SECRET`JWT 签名密钥(未设置时使用开发默认值)。
## 常用命令
```bash
npm run check
npm run lint
npm run build
```

73
api/app.ts Normal file
View File

@ -0,0 +1,73 @@
/**
* This is a API server
*/
import express, {
type Request,
type Response,
type NextFunction,
} from 'express'
import cors from 'cors'
import path from 'path'
import dotenv from 'dotenv'
import { fileURLToPath } from 'url'
import authRoutes from './routes/auth.js'
import ordersRoutes from './routes/orders.js'
import factoriesRoutes from './routes/factories.js'
import statsRoutes from './routes/stats.js'
// for esm mode
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// load env
dotenv.config()
const app: express.Application = express()
app.use(cors())
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
/**
* API Routes
*/
app.use('/api/auth', authRoutes)
app.use('/api/orders', ordersRoutes)
app.use('/api/factories', factoriesRoutes)
app.use('/api/stats', statsRoutes)
/**
* health
*/
app.use(
'/api/health',
(req: Request, res: Response, next: NextFunction): void => {
res.status(200).json({
success: true,
message: 'ok',
})
},
)
/**
* error handler middleware
*/
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({
success: false,
error: 'Server internal error',
})
})
/**
* 404 handler
*/
app.use((req: Request, res: Response) => {
res.status(404).json({
success: false,
error: 'API not found',
})
})
export default app

41
api/auth.ts Normal file
View File

@ -0,0 +1,41 @@
import jwt from 'jsonwebtoken'
import type { PublicUser, Role, UserRecord } from '../shared/types.js'
export const getJwtSecret = (): string => {
const secret = process.env.JWT_SECRET
return secret && secret.trim().length > 0 ? secret : 'dev-jwt-secret'
}
export const signToken = (user: PublicUser): string => {
return jwt.sign(
{
sub: user.id,
username: user.username,
role: user.role,
},
getJwtSecret(),
{ expiresIn: '7d' },
)
}
export const verifyToken = (
token: string,
): { userId: string; username: string; role: Role } | null => {
try {
const decoded = jwt.verify(token, getJwtSecret()) as {
sub?: string
username?: string
role?: Role
}
if (!decoded?.sub || !decoded.role || !decoded.username) return null
return { userId: decoded.sub, username: decoded.username, role: decoded.role }
} catch {
return null
}
}
export const toPublicUser = (u: UserRecord): PublicUser => {
const { passwordHash: _pw, ...rest } = u
return rest
}

464
api/data/db.json Normal file
View File

@ -0,0 +1,464 @@
{
"users": [
{
"id": "x_yCYfQ7DbFB7ge1-IJ3h",
"username": "admin",
"role": "admin",
"status": "active",
"passwordHash": "$2a$10$FhF.ryis3.YtdodZV/xhUuFAZyjEwJTGKht4/II0pfwd18GEsHK1q",
"createdAt": "2026-03-24T16:54:14.374Z",
"updatedAt": "2026-03-24T16:54:14.374Z"
},
{
"id": "gbvX31oSgxA2zbFt6cuF6",
"username": "sales",
"role": "sales",
"status": "active",
"passwordHash": "$2a$10$kt1o1bsxhZXs3vS1AaQqXecipLwR6ZQy7kNXPP2qodNPSS2VjDw1K",
"createdAt": "2026-03-24T16:54:14.374Z",
"updatedAt": "2026-03-24T16:54:14.374Z"
},
{
"id": "JiUhHFgzcOoxJV9gKF_md",
"username": "purchase",
"role": "purchase",
"status": "active",
"passwordHash": "$2a$10$73G4jZJc03KHZgle1gUef.Y1dC2WLxRu3A/WvzgngxRbwse6GYQSm",
"createdAt": "2026-03-24T16:54:14.374Z",
"updatedAt": "2026-03-24T16:54:14.374Z"
},
{
"id": "InWopwVm_iJxRjPjYL5Jz",
"username": "manager",
"role": "manager",
"status": "active",
"passwordHash": "$2a$10$W.iaxyym2QPvXIZrFM.3heRYSBOLt5JB0cr6ff647uhbkiXxUeuB6",
"createdAt": "2026-03-24T16:54:14.374Z",
"updatedAt": "2026-03-24T16:54:14.374Z"
}
],
"factories": [
{
"id": "CcAd3_yndV1_Yfh6KR-yQ",
"name": "A厂",
"contactPerson": "王工",
"contactPhone": "13800000001",
"contactEmail": "factory-a@example.com",
"address": "广东省深圳市",
"status": "active",
"createdAt": "2026-03-24T16:54:14.374Z",
"updatedAt": "2026-03-24T16:54:14.374Z"
},
{
"id": "gtIr3iLfS5PEw6Hom3Emd",
"name": "B厂",
"contactPerson": "李工",
"contactPhone": "13800000002",
"contactEmail": "factory-b@example.com",
"address": "浙江省宁波市",
"status": "active",
"createdAt": "2026-03-24T16:54:14.374Z",
"updatedAt": "2026-03-24T16:54:14.374Z"
}
],
"orders": [
{
"id": "Wby_7OPyNUM_OGFcqb2-Y",
"orderNo": "25FUT741147",
"poNo": "732536",
"productName": "砂光+镀钛金P4 16件套",
"country": "墨西哥",
"orderAmount": 8422.4,
"orderDate": "2025-07-16",
"customerDeliveryDate": "2025-11-11",
"factoryDeliveryDate": "2025-11-10",
"factoryContract": "已回传",
"orderProgress": "生产",
"remarks": "新品需寄样确认",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z",
"updatedAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "HsDPy04P-b9SKmaoKcO-I",
"orderNo": "25FUT641139",
"poNo": "731127",
"productName": "东润US5、浩润CT108D、CT108H",
"country": "墨西哥",
"orderAmount": 19490,
"orderDate": "2025-06-26",
"customerDeliveryDate": "2025-08-29",
"factoryDeliveryDate": "2025-08-29",
"factoryContract": "已回传",
"orderProgress": "验货",
"remarks": "送深圳保税仓",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z",
"updatedAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "Jt7v1np2-d2iQkzGKlqYe",
"orderNo": "25FUT741150",
"poNo": "290506",
"productName": "尼龙厨具四件套",
"country": "香港",
"orderAmount": 5340,
"orderDate": "2025-07-14",
"customerDeliveryDate": "2025-08-24",
"factoryDeliveryDate": "2025-08-20",
"factoryContract": "已回传合同",
"orderProgress": "出货",
"remarks": "需丝印logo",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z",
"updatedAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "qJjnljDJuT5aqjSeBw0hc",
"orderNo": "25FUT741144",
"poNo": "290366",
"productName": "尼龙厨具2件套餐更+密铲)",
"country": "中国",
"orderAmount": 900,
"orderDate": "2025-07-03",
"customerDeliveryDate": "2025-08-07",
"factoryDeliveryDate": "2025-08-02",
"factoryContract": "已回传合同",
"orderProgress": "验货",
"remarks": "手柄帽盖发黑需注意",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z",
"updatedAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "FBBULzzv6nGPrl2WHCgdE",
"orderNo": "25FUT641140",
"poNo": "290040",
"productName": "食品夹、蛋糕铲、Y形刨、开罐器、蛋糕铲头、纸卡",
"country": "洪都拉斯",
"orderAmount": 4572,
"orderDate": "2025-06-26",
"customerDeliveryDate": "2025-08-11",
"factoryDeliveryDate": "2025-08-02",
"factoryContract": "已回传合同",
"orderProgress": "验货",
"remarks": "需客户验货拼柜9月5号截止",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z",
"updatedAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "vBJwG_ZkyHK5vqFx4fz8i",
"orderNo": "26FUT141600",
"poNo": "BHC303",
"productName": "HO餐刀 CT108D CT1149 CT4 CT108D CT392A",
"country": "中国浙江",
"orderAmount": 355004.15,
"orderDate": "2026-01-16",
"customerDeliveryDate": "2026-04-01",
"factoryDeliveryDate": "2026-03-20",
"factoryContract": "已发给工厂",
"orderProgress": "生产",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z",
"updatedAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "FEVeXCKLxw-Y4-0UTuxw1",
"orderNo": "25FUT1241296",
"poNo": "BHC291",
"productName": "CT4C CT562P13",
"country": "中国浙江",
"orderAmount": 361113.6,
"orderDate": "2025-12-25",
"customerDeliveryDate": "2026-01-24",
"factoryContract": "已传合同",
"orderProgress": "生产",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z",
"updatedAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "n864Fiz1rnwy7cR5AvSEU",
"orderNo": "25FUT1241276",
"poNo": "BHC273",
"productName": "ZYB097-2-HO餐更四只装410、ZYB094-21MG-430",
"country": "中国浙江",
"orderAmount": 46897.2,
"customerDeliveryDate": "2026-01-07",
"factoryDeliveryDate": "2025-12-25",
"factoryContract": "已回传",
"orderProgress": "生产",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "valtPuiNognfIkvUftuJn",
"orderNo": "25FUT1141254",
"poNo": "BHC259",
"productName": "餐具",
"country": "中国浙江",
"orderAmount": 228284.08,
"customerDeliveryDate": "2026-01-05",
"factoryDeliveryDate": "2025-12-30",
"factoryContract": "已发给工厂",
"orderProgress": "生产",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "p682Et2F2whb0m_1jzd_x",
"orderNo": "25FUT1141251",
"poNo": "BHC255-2",
"productName": "餐具",
"country": "科索沃",
"orderAmount": 587118,
"factoryContract": "已发给工厂",
"orderProgress": "剩下50托镀钛黑",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "noKCH2atLYmov6j0fcVmJ",
"orderNo": "25FUT1141248",
"poNo": "BHC255-1",
"productName": "餐具",
"country": "阿尔巴尼亚",
"orderAmount": 237484.8,
"factoryContract": "已发给工厂",
"orderProgress": "生产",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "ad4ho5dGTr9sj7vCPLu6O",
"orderNo": "25FUT941213",
"poNo": "BHC231",
"productName": "CT4C CT562, CT1119",
"country": "中国浙江",
"orderAmount": 811632,
"orderDate": "2025-09-30",
"customerDeliveryDate": "2025-11-30",
"factoryDeliveryDate": "2025-11-19",
"factoryContract": "已传合同",
"orderProgress": "五金在抛光",
"remarks": "2025-11-17更新",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "hkejvXIaUjey4jv8EUJqS",
"orderNo": "25FUT941195",
"poNo": "BHC225",
"productName": "P165",
"country": "中国浙江",
"orderAmount": 22800,
"orderDate": "2025-09-12",
"customerDeliveryDate": "2025-09-30",
"factoryDeliveryDate": "2025-09-15",
"factoryContract": "已回传合同",
"orderProgress": "出货",
"remarks": "10.27需付款",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "oXXwEW0tSn1gotMPxDX4r",
"orderNo": "25FUT841178",
"poNo": "BHC208",
"productName": "CT562 P244",
"country": "中国浙江",
"orderAmount": 209587.52,
"orderDate": "2025-08-22",
"customerDeliveryDate": "2025-09-21",
"factoryDeliveryDate": "2025-09-21",
"factoryContract": "已回传合同",
"orderProgress": "生产",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "BYL7Q199ASlwIji2DFZML",
"orderNo": "25FUT341074",
"poNo": "BHC083ZYB096系列",
"productName": "CT108D餐具",
"country": "中国浙江",
"factoryContract": "已回传合同",
"orderProgress": "还剩下一箱钛金茶更",
"remarks": "10.27需付款",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "95y9yJTub4g56OoLHtO6M",
"orderNo": "24FUT1248321",
"poNo": "BHC082ZYB094系列",
"productName": "CT562餐具需要刀套",
"country": "中国浙江",
"factoryContract": "已回传合同",
"orderProgress": "完成",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "0RY6osRZEKn9ztul8CLfA",
"orderNo": "24FUT1248319",
"poNo": "BHC081ZYB095系列",
"productName": "CT4C餐具",
"country": "中国浙江",
"factoryContract": "已回传合同",
"orderProgress": "完成",
"remarks": "外箱需要打工字形打包带,可分批出货,出货后月结",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z",
"updatedAt": "2026-03-24T17:50:19.218Z"
}
],
"progressEvents": [
{
"id": "WmB4gQbF7iPzEeInnrykv",
"orderId": "Wby_7OPyNUM_OGFcqb2-Y",
"status": "生产",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "_pOi8u7bRhkrnfPKQQ_dJ",
"orderId": "HsDPy04P-b9SKmaoKcO-I",
"status": "验货",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "LqW0OOkG0Hg2oHBlHPs-b",
"orderId": "Jt7v1np2-d2iQkzGKlqYe",
"status": "出货",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "fea6RFHSUEtnSkcgXMx7z",
"orderId": "qJjnljDJuT5aqjSeBw0hc",
"status": "验货",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "R5wJFEjQqm1UQESbCxUmX",
"orderId": "FBBULzzv6nGPrl2WHCgdE",
"status": "验货",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "cFvbz8UEbiJ4ymaVcxWIj",
"orderId": "vBJwG_ZkyHK5vqFx4fz8i",
"status": "生产",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "uf1dm1s8BHKXELgZSBpSG",
"orderId": "FEVeXCKLxw-Y4-0UTuxw1",
"status": "生产",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.219Z"
},
{
"id": "j_912Ioqt8Nh1E202vs6m",
"orderId": "n864Fiz1rnwy7cR5AvSEU",
"status": "生产",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "J3xRtieg28j-o7D21nun6",
"orderId": "valtPuiNognfIkvUftuJn",
"status": "生产",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "Pt8jVHVPoGmV8GH345O7n",
"orderId": "p682Et2F2whb0m_1jzd_x",
"status": "剩下50托镀钛黑",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "dEk4HFGtiSTNcDwdOznCj",
"orderId": "noKCH2atLYmov6j0fcVmJ",
"status": "生产",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "RoaTLy8ktjDby6qCo0XCJ",
"orderId": "ad4ho5dGTr9sj7vCPLu6O",
"status": "五金在抛光",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "JSqm5dQDAZ-B3l3edjrZD",
"orderId": "hkejvXIaUjey4jv8EUJqS",
"status": "出货",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "Fy-kdpEQE7cmob2Tghtbf",
"orderId": "oXXwEW0tSn1gotMPxDX4r",
"status": "生产",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "hg-8iJkTmFvzhwoiFbuzn",
"orderId": "BYL7Q199ASlwIji2DFZML",
"status": "还剩下一箱钛金茶更",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "GYWePGO4sr92jkfepGDLm",
"orderId": "95y9yJTub4g56OoLHtO6M",
"status": "完成",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
},
{
"id": "12-MIn4RyXRT2xZGOv6-b",
"orderId": "0RY6osRZEKn9ztul8CLfA",
"status": "完成",
"note": "创建订单",
"createdByUserId": "x_yCYfQ7DbFB7ge1-IJ3h",
"createdAt": "2026-03-24T17:50:19.218Z"
}
]
}

457
api/db.ts Normal file
View File

@ -0,0 +1,457 @@
import fs from 'fs/promises'
import path from 'path'
import bcrypt from 'bcryptjs'
import { nanoid } from 'nanoid'
import type {
DbShape,
Factory,
Order,
OrderProgressEvent,
OrderProgressStatus,
PublicUser,
Role,
UserRecord,
} from '../shared/types.js'
const DATA_DIR = path.resolve(process.cwd(), 'api', 'data')
const DB_PATH = path.resolve(DATA_DIR, 'db.json')
let dbCache: DbShape | null = null
let writeLock: Promise<void> = Promise.resolve()
const nowIso = (): string => new Date().toISOString()
const sanitizeUser = (u: UserRecord): PublicUser => {
const { passwordHash: _pw, ...rest } = u
return rest
}
const ensureDataDir = async (): Promise<void> => {
await fs.mkdir(DATA_DIR, { recursive: true })
}
const seedFactories = (): Factory[] => {
const t = nowIso()
return [
{
id: nanoid(),
name: 'A厂',
contactPerson: '王工',
contactPhone: '13800000001',
contactEmail: 'factory-a@example.com',
address: '广东省深圳市',
status: 'active',
createdAt: t,
updatedAt: t,
},
{
id: nanoid(),
name: 'B厂',
contactPerson: '李工',
contactPhone: '13800000002',
contactEmail: 'factory-b@example.com',
address: '浙江省宁波市',
status: 'active',
createdAt: t,
updatedAt: t,
},
]
}
const seedUsers = async (): Promise<UserRecord[]> => {
const t = nowIso()
const makeUser = async (
username: string,
password: string,
role: Role,
): Promise<UserRecord> => {
const passwordHash = await bcrypt.hash(password, 10)
return {
id: nanoid(),
username,
role,
status: 'active',
passwordHash,
createdAt: t,
updatedAt: t,
}
}
return [
await makeUser('admin', 'admin123', 'admin'),
await makeUser('sales', 'sales123', 'sales'),
await makeUser('purchase', 'purchase123', 'purchase'),
await makeUser('manager', 'manager123', 'manager'),
]
}
const seedOrders = (users: UserRecord[], factories: Factory[]): Order[] => {
const t = nowIso()
const salesUser = users.find((u) => u.role === 'sales')
const f1 = factories[0]
const f2 = factories[1]
if (!salesUser) return []
return [
{
id: nanoid(),
orderNo: 'SO-2026-0001',
poNo: 'PO-10001',
productName: '玻璃杯',
country: '美国',
orderAmount: 120000,
orderDate: '2026-03-01',
customerDeliveryDate: '2026-04-05',
factoryId: f1?.id,
factoryDeliveryDate: '2026-03-28',
factoryContract: 'FC-2026-A-001',
packagingStatus: '进行中',
stickerStatus: '未开始',
shippingStatus: '未订舱',
inspectionStatus: '未验货',
purchaseAmount: 85000,
orderProgress: '生产',
remarks: '重点客户,优先排产',
ciAmount: 118000,
etd: '2026-04-02',
eta: '2026-04-22',
paymentDate: '2026-03-15',
paymentAmount: 60000,
balance: 60000,
createdByUserId: salesUser.id,
createdAt: t,
updatedAt: t,
},
{
id: nanoid(),
orderNo: 'SO-2026-0002',
poNo: 'PO-10002',
productName: '保温壶',
country: '德国',
orderAmount: 90000,
orderDate: '2026-03-08',
customerDeliveryDate: '2026-04-18',
factoryId: f2?.id,
factoryDeliveryDate: '2026-04-05',
factoryContract: 'FC-2026-B-002',
packagingStatus: '未开始',
stickerStatus: '未开始',
shippingStatus: '未订舱',
inspectionStatus: '未验货',
purchaseAmount: 62000,
orderProgress: '下单',
remarks: '',
ciAmount: 89000,
etd: '2026-04-12',
eta: '2026-05-02',
paymentDate: undefined,
paymentAmount: undefined,
balance: 90000,
createdByUserId: salesUser.id,
createdAt: t,
updatedAt: t,
},
]
}
const seedProgressEvents = (
orders: Order[],
users: UserRecord[],
): OrderProgressEvent[] => {
const t = nowIso()
const salesUser = users.find((u) => u.role === 'sales')
const purchaseUser = users.find((u) => u.role === 'purchase')
if (!salesUser) return []
const o1 = orders[0]
const o2 = orders[1]
const events: OrderProgressEvent[] = []
if (o1) {
events.push(
{
id: nanoid(),
orderId: o1.id,
status: '下单',
note: '订单录入完成',
createdByUserId: salesUser.id,
createdAt: t,
},
{
id: nanoid(),
orderId: o1.id,
status: '生产',
note: '已排产,预计本月完成',
createdByUserId: purchaseUser?.id ?? salesUser.id,
createdAt: t,
},
)
}
if (o2) {
events.push({
id: nanoid(),
orderId: o2.id,
status: '下单',
note: '等待工厂确认交期',
createdByUserId: salesUser.id,
createdAt: t,
})
}
return events
}
const createInitialDb = async (): Promise<DbShape> => {
const factories = seedFactories()
const users = await seedUsers()
const orders = seedOrders(users, factories)
const progressEvents = seedProgressEvents(orders, users)
return { users, factories, orders, progressEvents }
}
const readDbFromDisk = async (): Promise<DbShape | null> => {
try {
const raw = await fs.readFile(DB_PATH, 'utf-8')
const parsed = JSON.parse(raw) as DbShape
if (!parsed?.users || !parsed?.orders || !parsed?.factories) return null
return {
users: parsed.users ?? [],
factories: parsed.factories ?? [],
orders: parsed.orders ?? [],
progressEvents: parsed.progressEvents ?? [],
}
} catch {
return null
}
}
const persistDb = async (db: DbShape): Promise<void> => {
await ensureDataDir()
const tmp = DB_PATH + '.tmp'
await fs.writeFile(tmp, JSON.stringify(db, null, 2), 'utf-8')
try {
await fs.unlink(DB_PATH)
} catch {
}
await fs.rename(tmp, DB_PATH)
}
export const getDb = async (): Promise<DbShape> => {
if (dbCache) return dbCache
await ensureDataDir()
const loaded = await readDbFromDisk()
if (loaded) {
dbCache = loaded
return loaded
}
const seeded = await createInitialDb()
await persistDb(seeded)
dbCache = seeded
return seeded
}
export const updateDb = async (updater: (db: DbShape) => DbShape): Promise<DbShape> => {
writeLock = writeLock.then(async () => {
const db = await getDb()
const next = updater(JSON.parse(JSON.stringify(db)) as DbShape)
dbCache = next
await persistDb(next)
})
await writeLock
return (await getDb())
}
export const findUserByUsername = async (
username: string,
): Promise<UserRecord | undefined> => {
const db = await getDb()
return db.users.find((u) => u.username.toLowerCase() === username.toLowerCase())
}
export const createUser = async (params: {
username: string
password: string
role: Role
email?: string
}): Promise<PublicUser> => {
const { username, password, role, email } = params
const t = nowIso()
const passwordHash = await bcrypt.hash(password, 10)
const record: UserRecord = {
id: nanoid(),
username,
role,
email,
status: 'active',
passwordHash,
createdAt: t,
updatedAt: t,
}
await updateDb((db) => ({ ...db, users: [...db.users, record] }))
return sanitizeUser(record)
}
export const getPublicUsers = async (): Promise<PublicUser[]> => {
const db = await getDb()
return db.users.map(sanitizeUser)
}
export const listFactories = async (): Promise<Factory[]> => {
const db = await getDb()
return db.factories
}
export const listOrders = async (): Promise<Order[]> => {
const db = await getDb()
return db.orders
}
export const getOrder = async (id: string): Promise<Order | undefined> => {
const db = await getDb()
return db.orders.find((o) => o.id === id)
}
export const createOrder = async (params: {
input: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>
}): Promise<Order> => {
const t = nowIso()
const order: Order = {
id: nanoid(),
...params.input,
createdAt: t,
updatedAt: t,
}
await updateDb((db) => ({ ...db, orders: [order, ...db.orders] }))
return order
}
export const bulkCreateOrders = async (params: {
inputs: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>[]
createdByUserId: string
skipDuplicates?: boolean
}): Promise<{ created: Order[]; skippedOrderNos: string[] }> => {
const created: Order[] = []
const skippedOrderNos: string[] = []
await updateDb((db) => {
const existingOrderNos = new Set(db.orders.map((o) => o.orderNo))
const nextOrders = [...db.orders]
const nextProgressEvents = [...db.progressEvents]
for (const input of params.inputs) {
const orderNo = input.orderNo.trim()
if (!orderNo) continue
if (params.skipDuplicates && existingOrderNos.has(orderNo)) {
skippedOrderNos.push(orderNo)
continue
}
existingOrderNos.add(orderNo)
const t = nowIso()
const order: Order = {
id: nanoid(),
...input,
orderNo,
createdByUserId: params.createdByUserId,
createdAt: t,
updatedAt: t,
}
created.push(order)
nextOrders.unshift(order)
const evt: OrderProgressEvent = {
id: nanoid(),
orderId: order.id,
status: order.orderProgress,
note: '创建订单',
createdByUserId: params.createdByUserId,
createdAt: t,
}
nextProgressEvents.unshift(evt)
}
return { ...db, orders: nextOrders, progressEvents: nextProgressEvents }
})
return { created, skippedOrderNos }
}
export const updateOrder = async (params: {
id: string
patch: Partial<Order>
}): Promise<Order | null> => {
const { id, patch } = params
let updated: Order | null = null
await updateDb((db) => {
const nextOrders = db.orders.map((o) => {
if (o.id !== id) return o
updated = { ...o, ...patch, updatedAt: nowIso() }
return updated
})
return { ...db, orders: nextOrders }
})
return updated
}
export const deleteOrder = async (id: string): Promise<boolean> => {
let removed = false
await updateDb((db) => {
const nextOrders = db.orders.filter((o) => o.id !== id)
removed = nextOrders.length !== db.orders.length
const nextProgress = db.progressEvents.filter((e) => e.orderId !== id)
return { ...db, orders: nextOrders, progressEvents: nextProgress }
})
return removed
}
export const bulkDeleteOrders = async (params: { ids: string[] }): Promise<number> => {
const idSet = new Set(params.ids.filter((x) => x.trim().length > 0))
if (idSet.size === 0) return 0
let removedCount = 0
await updateDb((db) => {
const nextOrders = db.orders.filter((o) => {
if (!idSet.has(o.id)) return true
removedCount += 1
return false
})
const nextProgress = db.progressEvents.filter((e) => !idSet.has(e.orderId))
return { ...db, orders: nextOrders, progressEvents: nextProgress }
})
return removedCount
}
export const clearOrders = async (): Promise<number> => {
let removedCount = 0
await updateDb((db) => {
removedCount = db.orders.length
return { ...db, orders: [], progressEvents: [] }
})
return removedCount
}
export const listProgressEvents = async (orderId: string): Promise<OrderProgressEvent[]> => {
const db = await getDb()
return db.progressEvents
.filter((e) => e.orderId === orderId)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
}
export const addProgressEvent = async (params: {
orderId: string
status: OrderProgressStatus
note?: string
createdByUserId: string
}): Promise<OrderProgressEvent> => {
const evt: OrderProgressEvent = {
id: nanoid(),
orderId: params.orderId,
status: params.status,
note: params.note,
createdByUserId: params.createdByUserId,
createdAt: nowIso(),
}
await updateDb((db) => ({ ...db, progressEvents: [evt, ...db.progressEvents] }))
return evt
}

9
api/index.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
*/
import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from './app.js';
export default function handler(req: VercelRequest, res: VercelResponse) {
return app(req, res);
}

View File

@ -0,0 +1,46 @@
import type { NextFunction, Request, Response } from 'express'
import { verifyToken } from '../auth.js'
import { getDb } from '../db.js'
import type { PublicUser, Role, UserRecord } from '../../shared/types.js'
export type AuthedRequest = Request & { user: PublicUser }
const findUserById = (users: UserRecord[], id: string): UserRecord | undefined =>
users.find((u) => u.id === id)
export const requireAuth = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
const header = req.headers.authorization
const token = header?.startsWith('Bearer ') ? header.slice(7) : ''
const decoded = token ? verifyToken(token) : null
if (!decoded) {
res.status(401).json({ success: false, error: 'UNAUTHORIZED' })
return
}
const db = await getDb()
const record = findUserById(db.users, decoded.userId)
if (!record || record.status !== 'active') {
res.status(401).json({ success: false, error: 'UNAUTHORIZED' })
return
}
const { passwordHash: _pw, ...user } = record
;(req as AuthedRequest).user = user
next()
}
export const requireRole = (roles: Role[]) => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = (req as AuthedRequest).user
if (!user || !roles.includes(user.role)) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
next()
}
}

96
api/routes/auth.ts Normal file
View File

@ -0,0 +1,96 @@
/**
* This is a user authentication API route demo.
* Handle user registration, login, token management, etc.
*/
import { Router, type Request, type Response } from 'express'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { signToken, toPublicUser } from '../auth.js'
import { createUser, findUserByUsername } from '../db.js'
import { requireAuth } from '../middleware/requireAuth.js'
import type { AuthedRequest } from '../middleware/requireAuth.js'
import type { Role } from '../../shared/types.js'
const router = Router()
/**
* User Login
* POST /api/auth/register
*/
const registerSchema = z.object({
username: z.string().min(3).max(32),
password: z.string().min(6).max(64),
email: z.string().email().optional(),
})
router.post('/register', async (req: Request, res: Response): Promise<void> => {
const parsed = registerSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const existed = await findUserByUsername(parsed.data.username)
if (existed) {
res.status(409).json({ success: false, error: 'USERNAME_TAKEN' })
return
}
const user = await createUser({
username: parsed.data.username,
password: parsed.data.password,
role: 'sales',
email: parsed.data.email,
})
const token = signToken(user)
res.json({ success: true, data: { token, user } })
})
/**
* User Login
* POST /api/auth/login
*/
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
router.post('/login', async (req: Request, res: Response): Promise<void> => {
const parsed = loginSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const user = await findUserByUsername(parsed.data.username)
if (!user || user.status !== 'active') {
res.status(401).json({ success: false, error: 'INVALID_CREDENTIALS' })
return
}
const ok = await bcrypt.compare(parsed.data.password, user.passwordHash)
if (!ok) {
res.status(401).json({ success: false, error: 'INVALID_CREDENTIALS' })
return
}
const pub = toPublicUser(user)
const token = signToken(pub)
res.json({ success: true, data: { token, user: pub } })
})
/**
* User Logout
* POST /api/auth/logout
*/
router.post('/logout', async (_req: Request, res: Response): Promise<void> => {
res.json({ success: true })
})
router.get('/me', requireAuth, async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
res.json({ success: true, data: user })
})
export default router

15
api/routes/factories.ts Normal file
View File

@ -0,0 +1,15 @@
import { Router, type Request, type Response } from 'express'
import { listFactories } from '../db.js'
import { requireAuth } from '../middleware/requireAuth.js'
const router = Router()
router.use(requireAuth)
router.get('/', async (_req: Request, res: Response): Promise<void> => {
const factories = await listFactories()
res.json({ success: true, data: factories })
})
export default router

463
api/routes/orders.ts Normal file
View File

@ -0,0 +1,463 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import {
addProgressEvent,
bulkCreateOrders,
bulkDeleteOrders,
clearOrders,
createOrder,
deleteOrder,
getOrder,
listFactories,
listOrders,
listProgressEvents,
updateOrder,
} from '../db.js'
import type { AuthedRequest } from '../middleware/requireAuth.js'
import { requireAuth } from '../middleware/requireAuth.js'
import type { Order } from '../../shared/types.js'
const router = Router()
const parseNumber = (v: unknown): number | undefined => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (typeof v === 'string' && v.trim().length > 0 && Number.isFinite(Number(v))) {
return Number(v)
}
return undefined
}
const dateOrUndef = (v: unknown): string | undefined => {
if (typeof v !== 'string') return undefined
const s = v.trim()
return s.length > 0 ? s : undefined
}
const createOrderSchema = z.object({
orderNo: z.string().min(1),
poNo: z.string().optional(),
productName: z.string().min(1),
country: z.string().optional(),
orderAmount: z.union([z.number(), z.string()]).optional(),
orderDate: z.string().optional(),
customerDeliveryDate: z.string().optional(),
factoryId: z.string().optional(),
factoryDeliveryDate: z.string().optional(),
factoryContract: z.string().optional(),
packagingStatus: z.string().optional(),
stickerStatus: z.string().optional(),
shippingStatus: z.string().optional(),
inspectionStatus: z.string().optional(),
purchaseAmount: z.union([z.number(), z.string()]).optional(),
orderProgress: z.string().optional(),
remarks: z.string().optional(),
ciAmount: z.union([z.number(), z.string()]).optional(),
etd: z.string().optional(),
eta: z.string().optional(),
paymentDate: z.string().optional(),
paymentAmount: z.union([z.number(), z.string()]).optional(),
balance: z.union([z.number(), z.string()]).optional(),
})
const updateOrderSchema = createOrderSchema.partial()
const duplicatesSchema = z.object({
orderNos: z.array(z.string().min(1)).max(10000),
})
const bulkSchema = z.object({
items: z.array(createOrderSchema).min(1).max(5000),
skipDuplicates: z.boolean().optional(),
})
const deleteManySchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(10000),
})
router.use(requireAuth)
router.get('/', async (req: Request, res: Response): Promise<void> => {
const q = req.query
const page = Math.max(1, Number(q.page ?? 1) || 1)
const pageSize = Math.min(100, Math.max(1, Number(q.pageSize ?? 20) || 20))
const orderNo = typeof q.orderNo === 'string' ? q.orderNo.trim() : ''
const poNo = typeof q.poNo === 'string' ? q.poNo.trim() : ''
const productName = typeof q.productName === 'string' ? q.productName.trim() : ''
const country = typeof q.country === 'string' ? q.country.trim() : ''
const factoryId = typeof q.factoryId === 'string' ? q.factoryId.trim() : ''
const progress = typeof q.orderProgress === 'string' ? q.orderProgress.trim() : ''
const overdue = q.overdue === 'true'
const dateFrom = typeof q.dateFrom === 'string' ? q.dateFrom.trim() : ''
const dateTo = typeof q.dateTo === 'string' ? q.dateTo.trim() : ''
const orders = await listOrders()
const now = new Date().toISOString().slice(0, 10)
const filtered = orders.filter((o) => {
if (orderNo && !o.orderNo.toLowerCase().includes(orderNo.toLowerCase())) return false
if (poNo && !(o.poNo ?? '').toLowerCase().includes(poNo.toLowerCase())) return false
if (
productName &&
!o.productName.toLowerCase().includes(productName.toLowerCase())
) {
return false
}
if (country && !(o.country ?? '').toLowerCase().includes(country.toLowerCase())) {
return false
}
if (factoryId && (o.factoryId ?? '') !== factoryId) return false
if (progress && o.orderProgress !== progress) return false
if (dateFrom && (o.orderDate ?? '') < dateFrom) return false
if (dateTo && (o.orderDate ?? '') > dateTo) return false
if (overdue) {
const due = o.customerDeliveryDate
if (!due) return false
if (o.orderProgress === '完成' || o.orderProgress === '取消') return false
if (due >= now) return false
}
return true
})
const total = filtered.length
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
res.json({ success: true, data: { items, total, page, pageSize } })
})
router.post('/duplicates', async (req: Request, res: Response): Promise<void> => {
const parsed = duplicatesSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const orders = await listOrders()
const existing = new Set(orders.map((o) => o.orderNo))
const duplicates = parsed.data.orderNos.filter((x) => existing.has(x))
res.json({ success: true, data: { duplicates } })
})
router.post('/bulk', async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
if (!['sales', 'admin'].includes(user.role)) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
const parsed = bulkSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const items = parsed.data.items
const inputs: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>[] = items.map((body) => {
const orderProgress = (body.orderProgress ?? '下单').trim() || '下单'
return {
orderNo: body.orderNo,
poNo: body.poNo,
productName: body.productName,
country: body.country,
orderAmount: parseNumber(body.orderAmount),
orderDate: dateOrUndef(body.orderDate),
customerDeliveryDate: dateOrUndef(body.customerDeliveryDate),
factoryId: body.factoryId,
factoryDeliveryDate: dateOrUndef(body.factoryDeliveryDate),
factoryContract: body.factoryContract,
packagingStatus: body.packagingStatus,
stickerStatus: body.stickerStatus,
shippingStatus: body.shippingStatus,
inspectionStatus: body.inspectionStatus,
purchaseAmount: parseNumber(body.purchaseAmount),
orderProgress,
remarks: body.remarks,
ciAmount: parseNumber(body.ciAmount),
etd: dateOrUndef(body.etd),
eta: dateOrUndef(body.eta),
paymentDate: dateOrUndef(body.paymentDate),
paymentAmount: parseNumber(body.paymentAmount),
balance: parseNumber(body.balance),
createdByUserId: user.id,
}
})
const result = await bulkCreateOrders({
inputs,
createdByUserId: user.id,
skipDuplicates: parsed.data.skipDuplicates ?? true,
})
res.json({
success: true,
data: {
createdCount: result.created.length,
skippedOrderNos: result.skippedOrderNos,
},
})
})
router.post('/deleteMany', async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
if (!['sales', 'admin'].includes(user.role)) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
const parsed = deleteManySchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const removed = await bulkDeleteOrders({ ids: parsed.data.ids })
res.json({ success: true, data: { removed } })
})
router.post('/clear', async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
if (!['sales', 'admin'].includes(user.role)) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
const removed = await clearOrders()
res.json({ success: true, data: { removed } })
})
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
const id = req.params.id
const order = await getOrder(id)
if (!order) {
res.status(404).json({ success: false, error: 'NOT_FOUND' })
return
}
const factories = await listFactories()
const factory = factories.find((f) => f.id === order.factoryId)
const progressEvents = await listProgressEvents(id)
res.json({ success: true, data: { order, factory, progressEvents } })
})
router.post('/', async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
if (!['sales', 'admin'].includes(user.role)) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
const parsed = createOrderSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const body = parsed.data
const orderProgress = (body.orderProgress ?? '下单').trim() || '下单'
const input: Omit<Order, 'id' | 'createdAt' | 'updatedAt'> = {
orderNo: body.orderNo,
poNo: body.poNo,
productName: body.productName,
country: body.country,
orderAmount: parseNumber(body.orderAmount),
orderDate: dateOrUndef(body.orderDate),
customerDeliveryDate: dateOrUndef(body.customerDeliveryDate),
factoryId: body.factoryId,
factoryDeliveryDate: dateOrUndef(body.factoryDeliveryDate),
factoryContract: body.factoryContract,
packagingStatus: body.packagingStatus,
stickerStatus: body.stickerStatus,
shippingStatus: body.shippingStatus,
inspectionStatus: body.inspectionStatus,
purchaseAmount: parseNumber(body.purchaseAmount),
orderProgress,
remarks: body.remarks,
ciAmount: parseNumber(body.ciAmount),
etd: dateOrUndef(body.etd),
eta: dateOrUndef(body.eta),
paymentDate: dateOrUndef(body.paymentDate),
paymentAmount: parseNumber(body.paymentAmount),
balance: parseNumber(body.balance),
createdByUserId: user.id,
}
const created = await createOrder({ input })
await addProgressEvent({
orderId: created.id,
status: created.orderProgress,
note: '创建订单',
createdByUserId: user.id,
})
res.json({ success: true, data: created })
})
router.put('/:id', async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
const id = req.params.id
const existed = await getOrder(id)
if (!existed) {
res.status(404).json({ success: false, error: 'NOT_FOUND' })
return
}
const parsed = updateOrderSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const body = parsed.data
const isAdmin = user.role === 'admin'
const isSales = user.role === 'sales'
const isPurchase = user.role === 'purchase'
if (!isAdmin && !isSales && !isPurchase) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
const allowedFields: (keyof Order)[] = isAdmin
? [
'orderNo',
'poNo',
'productName',
'country',
'orderAmount',
'orderDate',
'customerDeliveryDate',
'factoryId',
'factoryDeliveryDate',
'factoryContract',
'packagingStatus',
'stickerStatus',
'shippingStatus',
'inspectionStatus',
'purchaseAmount',
'orderProgress',
'remarks',
'ciAmount',
'etd',
'eta',
'paymentDate',
'paymentAmount',
'balance',
]
: isSales
? [
'orderNo',
'poNo',
'productName',
'country',
'orderAmount',
'orderDate',
'customerDeliveryDate',
'factoryId',
'remarks',
'ciAmount',
'paymentDate',
'paymentAmount',
'balance',
]
: [
'factoryDeliveryDate',
'factoryContract',
'packagingStatus',
'stickerStatus',
'shippingStatus',
'inspectionStatus',
'purchaseAmount',
'orderProgress',
'etd',
'eta',
]
const patch: Partial<Order> = {}
for (const key of allowedFields) {
if (!(key in body)) continue
const value = (body as Record<string, unknown>)[key]
if (key.endsWith('Amount') || key === 'balance') {
(patch as Record<string, unknown>)[key] = parseNumber(value)
continue
}
if (
key.endsWith('Date') ||
key === 'etd' ||
key === 'eta'
) {
(patch as Record<string, unknown>)[key] = dateOrUndef(value)
continue
}
(patch as Record<string, unknown>)[key] = value
}
const updated = await updateOrder({ id, patch })
if (!updated) {
res.status(404).json({ success: false, error: 'NOT_FOUND' })
return
}
if (patch.orderProgress && patch.orderProgress !== existed.orderProgress) {
await addProgressEvent({
orderId: updated.id,
status: patch.orderProgress,
note: '更新订单状态',
createdByUserId: user.id,
})
}
res.json({ success: true, data: updated })
})
router.delete('/:id', async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
if (!['sales', 'admin'].includes(user.role)) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
const ok = await deleteOrder(req.params.id)
if (!ok) {
res.status(404).json({ success: false, error: 'NOT_FOUND' })
return
}
res.json({ success: true })
})
const progressSchema = z.object({
status: z.string().min(1),
note: z.string().optional(),
})
router.post('/:id/progress', async (req: Request, res: Response): Promise<void> => {
const user = (req as AuthedRequest).user
if (!['sales', 'purchase', 'admin'].includes(user.role)) {
res.status(403).json({ success: false, error: 'FORBIDDEN' })
return
}
const orderId = req.params.id
const existed = await getOrder(orderId)
if (!existed) {
res.status(404).json({ success: false, error: 'NOT_FOUND' })
return
}
const parsed = progressSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
return
}
const evt = await addProgressEvent({
orderId,
status: parsed.data.status,
note: parsed.data.note,
createdByUserId: user.id,
})
if (parsed.data.status !== existed.orderProgress) {
await updateOrder({ id: orderId, patch: { orderProgress: parsed.data.status } })
}
res.json({ success: true, data: evt })
})
export default router

123
api/routes/stats.ts Normal file
View File

@ -0,0 +1,123 @@
import { Router, type Request, type Response } from 'express'
import { listFactories, listOrders } from '../db.js'
import { requireAuth } from '../middleware/requireAuth.js'
const router = Router()
router.use(requireAuth)
const cleanText = (v: unknown): string => {
if (v === null || v === undefined) return ''
return String(v).replace(/\u00A0/g, ' ').replace(/[\s\u3000]+/g, ' ').trim()
}
const isDone = (status: string): boolean => {
const s = cleanText(status)
if (!s) return false
return /完成|已出完|出完|结束|已完成/.test(s)
}
const isCanceled = (status: string): boolean => {
const s = cleanText(status)
if (!s) return false
return /取消|作废/.test(s)
}
const normalizeStage = (status: string): string => {
const s = cleanText(status)
if (!s) return '未填写'
if (isCanceled(s)) return '取消'
if (isDone(s)) return '完成'
if (/验货|驗貨|待验货|已验货/.test(s)) return '验货'
if (/订舱|訂艙|订仓|订仓/.test(s)) return '订舱'
if (/包材|唛头|嘜頭/.test(s)) return '包材唛头'
if (/不干胶|外箱/.test(s)) return '外箱不干胶'
if (/出货|出運|出运|发货|發貨|已出货|待送货/.test(s)) return '出货'
if (/生产|待生产|排版|催大货样/.test(s)) return '生产'
if (/下单|下單/.test(s)) return '下单'
return '其他'
}
const dateKey = (s?: string): string => {
const v = cleanText(s)
return v ? v.slice(0, 10) : ''
}
const lastNDaysKeys = (n: number): string[] => {
const out: string[] = []
const now = new Date()
now.setHours(0, 0, 0, 0)
for (let i = n - 1; i >= 0; i -= 1) {
const d = new Date(now)
d.setDate(d.getDate() - i)
out.push(d.toISOString().slice(0, 10))
}
return out
}
router.get('/dashboard', async (_req: Request, res: Response): Promise<void> => {
const orders = await listOrders()
const factories = await listFactories()
const today = new Date().toISOString().slice(0, 10)
const active = orders.filter((o) => !isDone(o.orderProgress) && !isCanceled(o.orderProgress))
const totalAmount = orders.reduce((sum, o) => sum + (o.orderAmount ?? 0), 0)
const overdue = active.filter((o) => {
const due = dateKey(o.customerDeliveryDate)
if (!due) return false
return due < today
})
const statusMap = new Map<string, number>()
for (const o of orders) {
const stage = normalizeStage(o.orderProgress)
statusMap.set(stage, (statusMap.get(stage) ?? 0) + 1)
}
const statusDist = Array.from(statusMap.entries()).map(([name, value]) => ({
name,
value,
}))
const factoryNameById = new Map(factories.map((f) => [f.id, f.name]))
const factoryMap = new Map<string, number>()
for (const o of orders) {
const key = (o.factoryId ? factoryNameById.get(o.factoryId) : undefined) ?? '未指定'
factoryMap.set(key, (factoryMap.get(key) ?? 0) + 1)
}
const factoryDist = Array.from(factoryMap.entries()).map(([name, value]) => ({
name,
value,
}))
const days = lastNDaysKeys(30)
const seriesMap = new Map(days.map((d) => [d, { date: d, amount: 0, count: 0 }]))
for (const o of orders) {
const d = dateKey(o.orderDate) || dateKey(o.createdAt)
if (!d || !seriesMap.has(d)) continue
const row = seriesMap.get(d)!
row.amount += o.orderAmount ?? 0
row.count += 1
}
const series = days.map((d) => seriesMap.get(d)!)
res.json({
success: true,
data: {
kpis: {
totalOrders: orders.length,
activeOrders: active.length,
overdueOrders: overdue.length,
totalAmount,
},
charts: {
statusDist,
factoryDist,
trend: series,
},
},
})
})
export default router

34
api/server.ts Normal file
View File

@ -0,0 +1,34 @@
/**
* local server entry file, for local development
*/
import app from './app.js';
/**
* start server with port
*/
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`Server ready on port ${PORT}`);
});
/**
* close server
*/
process.on('SIGTERM', () => {
console.log('SIGTERM signal received');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('SIGINT signal received');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
export default app;

24
index.html Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Trae Project</title>
<script type="module">
if (import.meta.hot?.on) {
import.meta.hot.on('vite:error', (error) => {
if (error.err) {
console.error(
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
)
}
})
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10
nodemon.json Normal file
View File

@ -0,0 +1,10 @@
{
"watch": ["api"],
"ext": "ts,mts,js,json",
"ignore": ["api/dist/*"],
"exec": "node --loader ts-node/esm api/server.ts",
"env": {
"NODE_ENV": "development"
},
"delay": 1000
}

9222
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "trae-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"client:dev": "vite",
"server:dev": "nodemon",
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\"",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"check": "vue-tsc -b",
"lint": "eslint . --ext .ts,.vue",
"lint:fix": "eslint . --ext .ts,.vue --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"dayjs": "^1.11.12",
"dotenv": "^17.2.1",
"echarts": "^5.5.0",
"element-plus": "^2.7.8",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"lucide-vue-next": "^0.511.0",
"nanoid": "^4.0.2",
"pinia": "^2.2.7",
"tailwind-merge": "^3.3.0",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.15.30",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"@vitejs/plugin-vue": "^4.6.2",
"@vue/runtime-dom": "^3.4.15",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.17",
"concurrently": "^8.2.2",
"nodemon": "^3.1.10",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.1",
"postcss": "^8.4.31",
"tailwindcss": "3.3.5",
"ts-node": "^10.9.2",
"typescript": "~5.3.3",
"vite": "^4.5.0",
"vue-tsc": "^1.8.27"
}
}

10
postcss.config.js Normal file
View File

@ -0,0 +1,10 @@
/** WARNING: DON'T EDIT THIS FILE */
/** WARNING: DON'T EDIT THIS FILE */
/** WARNING: DON'T EDIT THIS FILE */
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

4
public/favicon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" fill="#0A0B0D"/>
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

80
shared/types.ts Normal file
View File

@ -0,0 +1,80 @@
export type Role = 'sales' | 'purchase' | 'manager' | 'admin'
export type UserStatus = 'active' | 'disabled'
export type OrderProgressStatus = string
export type FactoryStatus = 'active' | 'inactive'
export interface PublicUser {
id: string
username: string
email?: string
role: Role
status: UserStatus
createdAt: string
updatedAt: string
}
export interface UserRecord extends PublicUser {
passwordHash: string
}
export interface Factory {
id: string
name: string
contactPerson?: string
contactPhone?: string
contactEmail?: string
address?: string
status: FactoryStatus
createdAt: string
updatedAt: string
}
export interface Order {
id: string
orderNo: string
poNo?: string
productName: string
country?: string
orderAmount?: number
orderDate?: string
customerDeliveryDate?: string
factoryId?: string
factoryDeliveryDate?: string
factoryContract?: string
packagingStatus?: string
stickerStatus?: string
shippingStatus?: string
inspectionStatus?: string
purchaseAmount?: number
orderProgress: OrderProgressStatus
remarks?: string
ciAmount?: number
etd?: string
eta?: string
paymentDate?: string
paymentAmount?: number
balance?: number
createdByUserId: string
createdAt: string
updatedAt: string
}
export interface OrderProgressEvent {
id: string
orderId: string
status: OrderProgressStatus
note?: string
createdByUserId: string
createdAt: string
}
export interface DbShape {
users: UserRecord[]
factories: Factory[]
orders: Order[]
progressEvents: OrderProgressEvent[]
}

3
src/App.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import SideNav from '@/components/SideNav.vue'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const title = computed(() => {
const t = route.meta.title
return typeof t === 'string' ? t : '订单跟进'
})
const onLogout = async () => {
auth.logout()
await router.replace({ name: 'login' })
}
</script>
<template>
<el-container class="min-h-screen bg-zinc-50">
<el-aside width="240px" class="bg-white border-r border-zinc-200">
<div class="h-14 flex items-center px-4 border-b border-zinc-200">
<div class="font-semibold text-zinc-900">订单跟进</div>
</div>
<SideNav />
</el-aside>
<el-container>
<el-header class="h-14 bg-white border-b border-zinc-200 flex items-center">
<div class="flex-1 px-2">
<div class="text-sm text-zinc-500">{{ title }}</div>
</div>
<div class="flex items-center gap-2 pr-2">
<el-tag v-if="auth.user" size="small" type="info">{{ auth.user.username }} · {{ auth.user.role }}</el-tag>
<el-button size="small" @click="onLogout">退出</el-button>
</div>
</el-header>
<el-main class="p-4">
<router-view />
</el-main>
</el-container>
</el-container>
</template>

3
src/components/Empty.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div>empty</div>
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { DataAnalysis, List, Monitor } from '@element-plus/icons-vue'
type MenuItem = {
name: string
label: string
icon?: any
roles?: string[]
}
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const items = computed<MenuItem[]>(() => [
{ name: 'dashboard', label: '仪表盘', icon: Monitor },
{ name: 'orders', label: '订单管理', icon: List },
{ name: 'dashboard', label: '统计分析', icon: DataAnalysis, roles: ['manager', 'admin'] },
])
const visibleItems = computed(() => {
const r = auth.role
return items.value.filter((it) => !it.roles || (r && it.roles.includes(r)))
})
const active = computed(() => {
const n = route.name
return typeof n === 'string' ? n : ''
})
const onSelect = async (name: string) => {
await router.push({ name })
}
</script>
<template>
<el-menu :default-active="active" class="border-0" @select="onSelect">
<el-menu-item v-for="it in visibleItems" :key="it.name" :index="it.name">
<el-icon v-if="it.icon"><component :is="it.icon" /></el-icon>
<span>{{ it.label }}</span>
</el-menu-item>
</el-menu>
</template>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
const props = defineProps<{ option: echarts.EChartsCoreOption; height?: number }>()
const elRef = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
const render = () => {
if (!chart) return
chart.setOption(props.option, { notMerge: true })
}
onMounted(() => {
if (!elRef.value) return
chart = echarts.init(elRef.value)
render()
const onResize = () => chart?.resize()
window.addEventListener('resize', onResize)
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
})
})
watch(
() => props.option,
() => render(),
{ deep: true },
)
onBeforeUnmount(() => {
chart?.dispose()
chart = null
})
</script>
<template>
<div ref="elRef" :style="{ width: '100%', height: `${props.height ?? 280}px` }" />
</template>

View File

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '@/utils/api'
import type { Factory, Order, OrderProgressStatus } from '@/shared/types'
import { ORDER_PROGRESS_OPTIONS } from '@/utils/constants'
type Mode = 'create' | 'edit'
const props = defineProps<{ open: boolean; mode: Mode; factories: Factory[]; model?: Order | null }>()
const emit = defineEmits<{ (e: 'update:open', v: boolean): void; (e: 'saved'): void }>()
const title = computed(() => (props.mode === 'create' ? '新建订单' : '编辑订单'))
const form = reactive({
orderNo: '',
poNo: '',
productName: '',
country: '',
orderAmount: undefined as number | undefined,
orderDate: '' as string | '',
customerDeliveryDate: '' as string | '',
factoryId: '' as string | '',
orderProgress: '下单' as OrderProgressStatus,
remarks: '',
})
watch(
() => props.open,
(v) => {
if (!v) return
const m = props.model
if (props.mode === 'edit' && m) {
form.orderNo = m.orderNo
form.poNo = m.poNo ?? ''
form.productName = m.productName
form.country = m.country ?? ''
form.orderAmount = m.orderAmount
form.orderDate = m.orderDate ?? ''
form.customerDeliveryDate = m.customerDeliveryDate ?? ''
form.factoryId = m.factoryId ?? ''
form.orderProgress = m.orderProgress
form.remarks = m.remarks ?? ''
return
}
form.orderNo = ''
form.poNo = ''
form.productName = ''
form.country = ''
form.orderAmount = undefined
form.orderDate = ''
form.customerDeliveryDate = ''
form.factoryId = ''
form.orderProgress = '下单'
form.remarks = ''
},
)
const onClose = () => emit('update:open', false)
const onSave = async () => {
if (!form.orderNo.trim() || !form.productName.trim()) {
ElMessage.warning('请填写订单号与产品名称')
return
}
try {
if (props.mode === 'create') {
await api.createOrder({
orderNo: form.orderNo.trim(),
poNo: form.poNo.trim() || undefined,
productName: form.productName.trim(),
country: form.country.trim() || undefined,
orderAmount: form.orderAmount,
orderDate: form.orderDate || undefined,
customerDeliveryDate: form.customerDeliveryDate || undefined,
factoryId: form.factoryId || undefined,
orderProgress: form.orderProgress,
remarks: form.remarks.trim() || undefined,
} as Partial<Order> & { orderNo: string; productName: string })
} else {
const id = props.model?.id
if (!id) return
await api.updateOrder(id, {
orderNo: form.orderNo.trim(),
poNo: form.poNo.trim() || undefined,
productName: form.productName.trim(),
country: form.country.trim() || undefined,
orderAmount: form.orderAmount,
orderDate: form.orderDate || undefined,
customerDeliveryDate: form.customerDeliveryDate || undefined,
factoryId: form.factoryId || undefined,
orderProgress: form.orderProgress,
remarks: form.remarks.trim() || undefined,
})
}
ElMessage.success('已保存')
emit('saved')
emit('update:open', false)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存失败')
}
}
</script>
<template>
<el-dialog :model-value="open" :title="title" width="720px" @close="onClose">
<el-form label-position="top" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<el-form-item label="订单号" class="md:col-span-1">
<el-input v-model="form.orderNo" placeholder="例如 SO-2026-0003" />
</el-form-item>
<el-form-item label="PO号" class="md:col-span-1">
<el-input v-model="form.poNo" placeholder="例如 PO-10003" />
</el-form-item>
<el-form-item label="产品名称" class="md:col-span-1">
<el-input v-model="form.productName" placeholder="例如 玻璃杯" />
</el-form-item>
<el-form-item label="国别" class="md:col-span-1">
<el-input v-model="form.country" placeholder="例如 美国" />
</el-form-item>
<el-form-item label="订单金额" class="md:col-span-1">
<el-input-number v-model="form.orderAmount" :min="0" :step="1000" class="w-full" />
</el-form-item>
<el-form-item label="订单进度" class="md:col-span-1">
<el-select v-model="form.orderProgress" class="w-full">
<el-option v-for="o in ORDER_PROGRESS_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="下单日期" class="md:col-span-1">
<el-date-picker v-model="form.orderDate" type="date" value-format="YYYY-MM-DD" class="w-full" />
</el-form-item>
<el-form-item label="客人货期" class="md:col-span-1">
<el-date-picker v-model="form.customerDeliveryDate" type="date" value-format="YYYY-MM-DD" class="w-full" />
</el-form-item>
<el-form-item label="工厂" class="md:col-span-2">
<el-select v-model="form.factoryId" filterable clearable class="w-full" placeholder="选择工厂">
<el-option v-for="f in factories" :key="f.id" :label="f.name" :value="f.id" />
</el-select>
</el-form-item>
<el-form-item label="备注" class="md:col-span-2">
<el-input v-model="form.remarks" type="textarea" :rows="3" placeholder="可填写进度说明、风险点等" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onClose">取消</el-button>
<el-button type="primary" @click="onSave">保存</el-button>
</template>
</el-dialog>
</template>

View File

@ -0,0 +1,408 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { Factory, Order } from '@/shared/types'
import { api } from '@/utils/api'
import type { ImportPreviewRow } from '@/utils/orderImport'
import { buildImportPreview, readSpreadsheetRows } from '@/utils/orderImport'
const props = defineProps<{ open: boolean; factories: Factory[] }>()
const emit = defineEmits<{ (e: 'update:open', v: boolean): void; (e: 'imported'): void }>()
const importing = ref(false)
const parsing = ref(false)
const importMode = ref<'file' | 'json'>('file')
const fileName = ref('')
const jsonText = ref('')
const previewRows = ref<ImportPreviewRow[]>([])
const selectedKeys = ref<Set<number>>(new Set())
const tableRef = ref<
| {
clearSelection: () => void
toggleRowSelection: (row: ImportPreviewRow, selected?: boolean) => void
}
| null
>(null)
const skipDuplicates = ref(true)
const factoryNameById = computed(() => new Map(props.factories.map((f) => [f.id, f.name])))
const displayFactory = (row: ImportPreviewRow): string => {
return (
row.cleaned.factoryName ??
(row.cleaned.factoryId ? factoryNameById.value.get(row.cleaned.factoryId) : '') ??
row.cleaned.factoryId ??
''
)
}
const hasPreview = computed(() => previewRows.value.length > 0)
const errorCount = computed(
() => previewRows.value.filter((r) => r.issues.some((x) => x.severity === 'error')).length,
)
const warnCount = computed(() => previewRows.value.reduce((acc, r) => acc + r.issues.filter((x) => x.severity === 'warn').length, 0))
const selectedCount = computed(() => selectedKeys.value.size)
const duplicateCount = computed(() => previewRows.value.filter((r) => r.issues.some((x) => x.message.includes('订单号已存在'))).length)
const close = () => emit('update:open', false)
const reset = () => {
parsing.value = false
importing.value = false
importMode.value = 'file'
fileName.value = ''
jsonText.value = ''
previewRows.value = []
selectedKeys.value = new Set()
skipDuplicates.value = true
}
const syncTableSelection = async () => {
await nextTick()
if (!tableRef.value) return
tableRef.value.clearSelection()
for (const r of previewRows.value) {
if (selectedKeys.value.has(r.index)) {
tableRef.value.toggleRowSelection(r, true)
}
}
}
watch(
() => props.open,
async (v) => {
if (!v) {
reset()
return
}
},
)
const onPickFile = async (file: File) => {
parsing.value = true
try {
fileName.value = file.name
const rawRows = await readSpreadsheetRows(file)
const preview0 = buildImportPreview({ rawRows, factories: props.factories })
const orderNos = preview0.rows.map((r) => r.cleaned.orderNo).filter((x): x is string => Boolean(x))
let duplicates: string[] = []
try {
duplicates = orderNos.length > 0 ? await api.checkOrderDuplicates(orderNos) : []
} catch {
duplicates = []
}
const preview = buildImportPreview({
rawRows,
factories: props.factories,
existingOrderNos: new Set(duplicates),
})
previewRows.value = preview.rows
const selected = new Set<number>()
for (const r of preview.rows) {
const hasErrors = r.issues.some((x) => x.severity === 'error')
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
if (!hasErrors && (!skipDuplicates.value || !isDup)) {
selected.add(r.index)
}
}
selectedKeys.value = selected
await syncTableSelection()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '解析失败')
} finally {
parsing.value = false
}
}
const onUploadChange = async (uploadFile: { raw?: File }) => {
const f = uploadFile.raw
if (!f) return
await onPickFile(f)
}
const onSkipDuplicatesChange = (v: boolean) => {
skipDuplicates.value = v
if (!hasPreview.value) return
const selected = new Set(selectedKeys.value)
for (const r of previewRows.value) {
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
const hasErrors = r.issues.some((x) => x.severity === 'error')
if (hasErrors) {
selected.delete(r.index)
continue
}
if (skipDuplicates.value && isDup) selected.delete(r.index)
if (!skipDuplicates.value && !selected.has(r.index)) selected.add(r.index)
}
selectedKeys.value = selected
void syncTableSelection()
}
const rowClassName = (args: { row: ImportPreviewRow }) => {
const hasErrors = args.row.issues.some((x) => x.severity === 'error')
return hasErrors ? 'bg-red-50' : ''
}
const isRowSelectable = (row: ImportPreviewRow) => !row.issues.some((x) => x.severity === 'error')
const onSelectionChange = (rows: ImportPreviewRow[]) => {
selectedKeys.value = new Set(rows.map((r) => r.index))
}
const onDownloadTemplate = () => {
const headers = [
'订单号',
'PO号',
'产品名称',
'国别',
'订单金额',
'下单日期',
'客人货期',
'工厂名称',
'工厂货期',
'合同状态',
'订单进度',
'备注',
]
const csv = `${headers.join(',')}\n`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '订单导入模板.csv'
a.click()
URL.revokeObjectURL(a.href)
}
const onParseJson = async () => {
parsing.value = true
try {
const t = jsonText.value.trim()
if (!t) {
ElMessage.warning('请粘贴JSON数据')
return
}
const parsed = JSON.parse(t)
if (!Array.isArray(parsed)) {
ElMessage.error('JSON必须是数组')
return
}
fileName.value = '粘贴JSON'
const rawRows = parsed as Record<string, unknown>[]
const preview0 = buildImportPreview({ rawRows, factories: props.factories })
const orderNos = preview0.rows.map((r) => r.cleaned.orderNo).filter((x): x is string => Boolean(x))
let duplicates: string[] = []
try {
duplicates = orderNos.length > 0 ? await api.checkOrderDuplicates(orderNos) : []
} catch {
duplicates = []
}
const preview = buildImportPreview({
rawRows,
factories: props.factories,
existingOrderNos: new Set(duplicates),
})
previewRows.value = preview.rows
const selected = new Set<number>()
for (const r of preview.rows) {
const hasErrors = r.issues.some((x) => x.severity === 'error')
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
if (!hasErrors && (!skipDuplicates.value || !isDup)) selected.add(r.index)
}
selectedKeys.value = selected
await syncTableSelection()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : 'JSON解析失败')
} finally {
parsing.value = false
}
}
const toCreateInput = (row: ImportPreviewRow): Partial<Order> & { orderNo: string; productName: string } | null => {
const c = row.cleaned
if (!c.orderNo || !c.productName) return null
const isDup = row.issues.some((x) => x.message.includes('订单号已存在'))
if (skipDuplicates.value && isDup) return null
return {
orderNo: c.orderNo,
poNo: c.poNo,
productName: c.productName,
country: c.country,
orderAmount: c.orderAmount,
orderDate: c.orderDate,
customerDeliveryDate: c.customerDeliveryDate,
factoryId: c.factoryId,
factoryDeliveryDate: c.factoryDeliveryDate,
factoryContract: c.factoryContract,
packagingStatus: c.packagingStatus,
stickerStatus: c.stickerStatus,
shippingStatus: c.shippingStatus,
inspectionStatus: c.inspectionStatus,
purchaseAmount: c.purchaseAmount,
orderProgress: c.orderProgress ?? '下单',
remarks: c.remarks,
ciAmount: c.ciAmount,
etd: c.etd,
eta: c.eta,
paymentDate: c.paymentDate,
paymentAmount: c.paymentAmount,
balance: c.balance,
}
}
const onImport = async () => {
if (!hasPreview.value) {
ElMessage.warning('请先选择文件')
return
}
if (selectedKeys.value.size === 0) {
ElMessage.warning('没有可导入的行')
return
}
importing.value = true
try {
const items = previewRows.value
.filter((r) => selectedKeys.value.has(r.index))
.filter((r) => !r.issues.some((x) => x.severity === 'error'))
.map((r) => toCreateInput(r))
.filter(
(x): x is Partial<Order> & { orderNo: string; productName: string } => x !== null,
)
const res = await api.bulkCreateOrders({ items, skipDuplicates: skipDuplicates.value })
const skipped = res.skippedOrderNos.length
ElMessage.success(`导入完成:成功 ${res.createdCount},跳过重复 ${skipped}`)
emit('imported')
emit('update:open', false)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '导入失败')
} finally {
importing.value = false
}
}
const title = computed(() => (fileName.value ? `导入订单 - ${fileName.value}` : '导入订单'))
</script>
<template>
<el-dialog :model-value="open" :title="title" width="1100px" @close="close">
<div class="space-y-3">
<el-radio-group v-model="importMode" size="small">
<el-radio-button label="file">文件导入</el-radio-button>
<el-radio-button label="json">粘贴JSON</el-radio-button>
</el-radio-group>
<div class="flex items-center gap-2">
<el-upload
v-if="importMode==='file'"
:show-file-list="false"
accept=".xlsx,.xls,.csv"
:auto-upload="false"
:on-change="onUploadChange"
>
<el-button type="primary" :loading="parsing">选择Excel/CSV</el-button>
</el-upload>
<el-button v-else type="primary" :loading="parsing" @click="onParseJson">解析JSON</el-button>
<el-button @click="onDownloadTemplate">下载模板</el-button>
<div class="flex-1" />
<el-switch :model-value="skipDuplicates" @change="onSkipDuplicatesChange" />
<span class="text-xs text-zinc-600">跳过重复订单号</span>
</div>
<el-input
v-if="importMode==='json' && !hasPreview"
v-model="jsonText"
type="textarea"
:rows="10"
placeholder="粘贴 JSON 数组(例如 [{...},{...}]),字段支持 orderNo/poNo/product/country/factory/contractStatus/orderProgress/remarks 等"
/>
<el-alert
v-if="hasPreview"
type="info"
show-icon
:closable="false"
:title="`共 ${previewRows.length} 行;错误 ${errorCount} 行;警告 ${warnCount} 条;重复 ${duplicateCount} 行;已选择 ${selectedCount} 行导入`"
/>
<el-table
v-if="hasPreview"
ref="tableRef"
:data="previewRows"
stripe
class="w-full"
height="520"
:row-key="(r) => r.index"
reserve-selection
:row-class-name="rowClassName"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" :selectable="isRowSelectable" />
<el-table-column prop="index" label="#" width="60" />
<el-table-column label="订单号" min-width="140">
<template #default="scope">
<span>{{ scope.row.cleaned.orderNo }}</span>
</template>
</el-table-column>
<el-table-column label="产品" min-width="160">
<template #default="scope">
<span>{{ scope.row.cleaned.productName }}</span>
</template>
</el-table-column>
<el-table-column label="国别" width="100">
<template #default="scope">
<span>{{ scope.row.cleaned.country }}</span>
</template>
</el-table-column>
<el-table-column label="工厂" width="160">
<template #default="scope">
<span>{{ displayFactory(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="金额" width="120">
<template #default="scope">
<span>{{ scope.row.cleaned.orderAmount ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="下单" width="110">
<template #default="scope">
<span>{{ scope.row.cleaned.orderDate ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="客人货期" width="110">
<template #default="scope">
<span>{{ scope.row.cleaned.customerDeliveryDate ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="进度" width="110">
<template #default="scope">
<span>{{ scope.row.cleaned.orderProgress ?? '下单' }}</span>
</template>
</el-table-column>
<el-table-column label="合同" width="140">
<template #default="scope">
<span>{{ scope.row.cleaned.factoryContract ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="问题" min-width="260">
<template #default="scope">
<div class="space-y-1">
<div v-for="(it, idx) in scope.row.issues" :key="idx" class="text-xs" :class="it.severity==='error' ? 'text-red-600' : 'text-amber-600'">
{{ it.severity === 'error' ? '错误' : '警告' }}{{ it.message }}
</div>
</div>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button v-if="hasPreview" @click="reset" :disabled="parsing || importing">清空</el-button>
<el-button type="primary" :loading="importing" :disabled="parsing" @click="onImport">开始导入</el-button>
</template>
</el-dialog>
</template>

View File

@ -0,0 +1,40 @@
import { ref, watchEffect, onMounted, computed } from 'vue'
type Theme = 'light' | 'dark'
export function useTheme() {
const theme = ref<Theme>('light')
const getPreferredTheme = (): Theme => {
const saved = localStorage.getItem('theme') as Theme | null
if (saved === 'light' || saved === 'dark') return saved
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
const applyTheme = (t: Theme) => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(t)
localStorage.setItem('theme', t)
}
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
onMounted(() => {
theme.value = getPreferredTheme()
applyTheme(theme.value)
})
watchEffect(() => {
applyTheme(theme.value)
})
return {
theme,
toggleTheme,
isDark: computed(() => theme.value === 'dark'),
}
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

18
src/main.ts Normal file
View File

@ -0,0 +1,18 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// 创建Vue应用实例
const app = createApp(App)
// 使用路由
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
// 挂载应用
app.mount('#app')

130
src/pages/DashboardPage.vue Normal file
View File

@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '@/utils/api'
import EChart from '@/components/charts/EChart.vue'
const loading = ref(false)
const kpis = ref({
totalOrders: 0,
activeOrders: 0,
overdueOrders: 0,
totalAmount: 0,
})
const charts = ref({
statusDist: [] as { name: string; value: number }[],
factoryDist: [] as { name: string; value: number }[],
trend: [] as { date: string; amount: number; count: number }[],
})
const load = async () => {
loading.value = true
try {
const res = await api.dashboard()
kpis.value = res.kpis
charts.value = res.charts
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
const statusOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { top: 'bottom' },
series: [
{
type: 'pie',
radius: ['35%', '65%'],
data: charts.value.statusDist,
label: { formatter: '{b}: {c}' },
},
],
}))
const factoryOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: 24, right: 16, top: 24, bottom: 24, containLabel: true },
xAxis: {
type: 'category',
data: charts.value.factoryDist.map((x) => x.name),
axisLabel: { interval: 0, rotate: 20 },
},
yAxis: { type: 'value' },
series: [
{
type: 'bar',
data: charts.value.factoryDist.map((x) => x.value),
itemStyle: { color: '#2563eb' },
},
],
}))
const trendOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: 24, right: 16, top: 24, bottom: 24, containLabel: true },
xAxis: { type: 'category', data: charts.value.trend.map((x) => x.date) },
yAxis: [{ type: 'value', name: '金额' }, { type: 'value', name: '数量' }],
series: [
{
name: '金额',
type: 'line',
smooth: true,
data: charts.value.trend.map((x) => x.amount),
itemStyle: { color: '#2563eb' },
},
{
name: '数量',
type: 'bar',
yAxisIndex: 1,
data: charts.value.trend.map((x) => x.count),
itemStyle: { color: '#22c55e' },
},
],
}))
onMounted(load)
</script>
<template>
<div class="space-y-4" v-loading="loading">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<el-card shadow="never">
<div class="text-xs text-zinc-500">订单总数</div>
<div class="text-2xl font-semibold text-zinc-900">{{ kpis.totalOrders }}</div>
</el-card>
<el-card shadow="never">
<div class="text-xs text-zinc-500">进行中</div>
<div class="text-2xl font-semibold text-zinc-900">{{ kpis.activeOrders }}</div>
</el-card>
<el-card shadow="never">
<div class="text-xs text-zinc-500">逾期订单</div>
<div class="text-2xl font-semibold text-red-600">{{ kpis.overdueOrders }}</div>
</el-card>
<el-card shadow="never">
<div class="text-xs text-zinc-500">订单金额合计</div>
<div class="text-2xl font-semibold text-zinc-900">{{ kpis.totalAmount }}</div>
</el-card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<el-card shadow="never">
<div class="font-semibold text-zinc-900 mb-3">订单状态分布</div>
<EChart :option="statusOption" :height="320" />
</el-card>
<el-card shadow="never">
<div class="font-semibold text-zinc-900 mb-3">工厂订单分布</div>
<EChart :option="factoryOption" :height="320" />
</el-card>
</div>
<el-card shadow="never">
<div class="font-semibold text-zinc-900 mb-3"> 30 天订单趋势金额/数量</div>
<EChart :option="trendOption" :height="340" />
</el-card>
</div>
</template>

6
src/pages/HomePage.vue Normal file
View File

@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<div></div>
</template>

81
src/pages/LoginPage.vue Normal file
View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const mode = ref<'login' | 'register'>('login')
const loading = ref(false)
const form = reactive({
username: '',
password: '',
email: '',
})
const onSubmit = async () => {
loading.value = true
try {
if (mode.value === 'login') {
await auth.login({ username: form.username, password: form.password })
} else {
await auth.register({ username: form.username, password: form.password, email: form.email || undefined })
}
await router.replace({ name: 'dashboard' })
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '操作失败')
} finally {
loading.value = false
}
}
const useDemo = (u: string, p: string) => {
form.username = u
form.password = p
}
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-zinc-50 flex items-center justify-center p-4">
<div class="w-full max-w-md">
<el-card class="shadow-sm">
<div class="mb-4">
<div class="text-lg font-semibold text-zinc-900">订单跟进系统</div>
<div class="text-xs text-zinc-500">登录后按角色显示可用功能</div>
</div>
<el-segmented v-model="mode" :options="[{label:'登录',value:'login'},{label:'注册',value:'register'}]" class="mb-4" />
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名">
<el-input v-model="form.username" autocomplete="username" placeholder="例如 sales" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" autocomplete="current-password" placeholder="例如 sales123" show-password />
</el-form-item>
<el-form-item v-if="mode==='register'" label="邮箱(可选)">
<el-input v-model="form.email" autocomplete="email" placeholder="name@example.com" />
</el-form-item>
<el-button type="primary" class="w-full" :loading="loading" @click="onSubmit">
{{ mode === 'login' ? '登录' : '注册' }}
</el-button>
</el-form>
<div class="mt-4">
<div class="text-xs text-zinc-500 mb-2">示例账号</div>
<div class="flex flex-wrap gap-2">
<el-button size="small" @click="useDemo('sales','sales123')">sales</el-button>
<el-button size="small" @click="useDemo('purchase','purchase123')">purchase</el-button>
<el-button size="small" @click="useDemo('manager','manager123')">manager</el-button>
<el-button size="small" @click="useDemo('admin','admin123')">admin</el-button>
</div>
</div>
</el-card>
</div>
</div>
</template>

View File

@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { ElMessage } from 'element-plus'
import { api } from '@/utils/api'
import { useAuthStore } from '@/stores/auth'
import type { Factory, Order, OrderProgressEvent, OrderProgressStatus } from '@/shared/types'
import { ORDER_PROGRESS_OPTIONS } from '@/utils/constants'
import OrderFormDialog from '@/components/orders/OrderFormDialog.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const id = computed(() => String(route.params.id || ''))
const loading = ref(false)
const order = ref<Order | null>(null)
const factory = ref<Factory | undefined>(undefined)
const progressEvents = ref<OrderProgressEvent[]>([])
const factories = ref<Factory[]>([])
const canEdit = computed(() => ['sales', 'purchase', 'admin'].includes(auth.role ?? ''))
const canAddProgress = computed(() => ['sales', 'purchase', 'admin'].includes(auth.role ?? ''))
const progressForm = ref<{ status: OrderProgressStatus; note: string }>({
status: '生产',
note: '',
})
const editOpen = ref(false)
const load = async () => {
if (!id.value) return
loading.value = true
try {
if (factories.value.length === 0) {
factories.value = await api.listFactories()
}
const res = await api.getOrderDetail(id.value)
order.value = res.order
factory.value = res.factory
progressEvents.value = res.progressEvents
progressForm.value.status = res.order.orderProgress
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
const onBack = async () => {
await router.push({ name: 'orders' })
}
const onAddProgress = async () => {
if (!order.value) return
try {
await api.addProgress(order.value.id, {
status: progressForm.value.status,
note: progressForm.value.note.trim() || undefined,
})
progressForm.value.note = ''
await load()
ElMessage.success('已更新')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '更新失败')
}
}
const openEdit = () => {
editOpen.value = true
}
onMounted(load)
</script>
<template>
<div class="space-y-4" v-loading="loading">
<div class="flex items-center gap-2">
<el-button @click="onBack">返回</el-button>
<div class="flex-1" />
<el-button v-if="canEdit && order" type="primary" @click="openEdit">编辑订单</el-button>
</div>
<el-card shadow="never" v-if="order">
<div class="flex items-center gap-2 mb-3">
<div class="text-base font-semibold text-zinc-900">{{ order.orderNo }}</div>
<el-tag size="small" type="info">{{ order.orderProgress }}</el-tag>
</div>
<el-descriptions :column="3" border>
<el-descriptions-item label="产品">{{ order.productName }}</el-descriptions-item>
<el-descriptions-item label="国别">{{ order.country || '-' }}</el-descriptions-item>
<el-descriptions-item label="工厂">{{ factory?.name || '未指定' }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ order.orderAmount ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="下单日期">{{ order.orderDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="客人货期">{{ order.customerDeliveryDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="包材/唛头">{{ order.packagingStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="外箱不干胶">{{ order.stickerStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="订舱">{{ order.shippingStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="验货">{{ order.inspectionStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="ETD">{{ order.etd || '-' }}</el-descriptions-item>
<el-descriptions-item label="ETA">{{ order.eta || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{ order.remarks || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" v-if="order">
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-zinc-900">进度历史</div>
<div class="text-xs text-zinc-500">双击订单列表可快速进入详情</div>
</div>
<div v-if="canAddProgress" class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<el-select v-model="progressForm.status" class="w-full">
<el-option v-for="o in ORDER_PROGRESS_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-input v-model="progressForm.note" placeholder="备注(可选)" class="w-full" />
<el-button type="primary" @click="onAddProgress">新增进度记录</el-button>
</div>
<el-timeline>
<el-timeline-item v-for="e in progressEvents" :key="e.id" :timestamp="dayjs(e.createdAt).format('YYYY-MM-DD HH:mm')">
<div class="flex items-center gap-2">
<el-tag size="small" type="success">{{ e.status }}</el-tag>
<span class="text-sm text-zinc-700">{{ e.note || '—' }}</span>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
<OrderFormDialog
v-model:open="editOpen"
mode="edit"
:factories="factories"
:model="order"
@saved="load"
/>
</div>
</template>

275
src/pages/OrdersPage.vue Normal file
View File

@ -0,0 +1,275 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '@/utils/api'
import { useAuthStore } from '@/stores/auth'
import type { Factory, Order } from '@/shared/types'
import OrderFormDialog from '@/components/orders/OrderFormDialog.vue'
import OrderImportDialog from '@/components/orders/OrderImportDialog.vue'
import { ORDER_PROGRESS_OPTIONS } from '@/utils/constants'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const factories = ref<Factory[]>([])
const factoryNameById = computed(() => new Map(factories.value.map((f) => [f.id, f.name])))
const filters = reactive({
orderNo: '',
poNo: '',
productName: '',
country: '',
factoryId: '',
orderProgress: '',
overdue: false,
dateRange: [] as string[],
})
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const rows = ref<Order[]>([])
const tableRef = ref<
| {
clearSelection: () => void
}
| null
>(null)
const selectedRows = ref<Order[]>([])
const selectedIds = computed(() => selectedRows.value.map((x) => x.id))
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const selected = ref<Order | null>(null)
const importOpen = ref(false)
const canWrite = computed(() => ['sales', 'admin'].includes(auth.role ?? ''))
const canDelete = computed(() => ['sales', 'admin'].includes(auth.role ?? ''))
const load = async () => {
loading.value = true
try {
if (factories.value.length === 0) {
factories.value = await api.listFactories()
}
const [dateFrom, dateTo] = filters.dateRange
const res = await api.listOrders({
page: page.value,
pageSize: pageSize.value,
orderNo: filters.orderNo || undefined,
poNo: filters.poNo || undefined,
productName: filters.productName || undefined,
country: filters.country || undefined,
factoryId: filters.factoryId || undefined,
orderProgress: filters.orderProgress || undefined,
overdue: filters.overdue || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
})
rows.value = res.items
total.value = res.total
tableRef.value?.clearSelection()
selectedRows.value = []
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
const onSelectionChange = (v: Order[]) => {
selectedRows.value = v
}
const onSearch = async () => {
page.value = 1
await load()
}
const onReset = async () => {
filters.orderNo = ''
filters.poNo = ''
filters.productName = ''
filters.country = ''
filters.factoryId = ''
filters.orderProgress = ''
filters.overdue = false
filters.dateRange = []
await onSearch()
}
const openCreate = () => {
dialogMode.value = 'create'
selected.value = null
dialogOpen.value = true
}
const openEdit = (row: Order) => {
dialogMode.value = 'edit'
selected.value = row
dialogOpen.value = true
}
const onDelete = async (row: Order) => {
try {
await ElMessageBox.confirm(`确认删除订单 ${row.orderNo}`, '删除确认', { type: 'warning' })
await api.deleteOrder(row.id)
ElMessage.success('已删除')
await load()
} catch {}
}
const onDeleteSelected = async () => {
if (selectedIds.value.length === 0) return
try {
await ElMessageBox.confirm(
`确认删除已选择的 ${selectedIds.value.length} 条订单?`,
'批量删除确认',
{ type: 'warning' },
)
const res = await api.deleteOrders(selectedIds.value)
ElMessage.success(`已删除 ${res.removed}`)
await load()
} catch {}
}
const onClearAll = async () => {
try {
await ElMessageBox.confirm(
'将清空所有订单与进度记录,且无法恢复。确认继续?',
'清空确认',
{ type: 'warning', confirmButtonText: '确认清空' },
)
const res = await api.clearOrders()
ElMessage.success(`已清空 ${res.removed}`)
await load()
} catch {}
}
const openDetail = async (row: Order) => {
await router.push({ name: 'orderDetail', params: { id: row.id } })
}
const openImport = async () => {
try {
if (factories.value.length === 0) {
factories.value = await api.listFactories()
}
importOpen.value = true
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载工厂失败')
}
}
onMounted(load)
</script>
<template>
<div class="space-y-4">
<el-card shadow="never">
<div class="flex flex-col gap-3">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<el-input v-model="filters.orderNo" placeholder="订单号" clearable />
<el-input v-model="filters.poNo" placeholder="PO号" clearable />
<el-input v-model="filters.productName" placeholder="产品名称" clearable />
<el-input v-model="filters.country" placeholder="国别" clearable />
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<el-select v-model="filters.factoryId" filterable clearable placeholder="工厂">
<el-option v-for="f in factories" :key="f.id" :label="f.name" :value="f.id" />
</el-select>
<el-select v-model="filters.orderProgress" clearable placeholder="订单进度">
<el-option v-for="o in ORDER_PROGRESS_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="-" start-placeholder="下单开始" end-placeholder="下单结束" />
<div class="flex items-center gap-2">
<el-switch v-model="filters.overdue" />
<span class="text-xs text-zinc-600">仅逾期</span>
</div>
</div>
<div class="flex items-center gap-2">
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
<div class="flex-1" />
<el-button v-if="canWrite" @click="openImport">导入</el-button>
<el-button
v-if="canDelete"
type="danger"
plain
:disabled="selectedIds.length===0"
@click="onDeleteSelected"
>
批量删除
</el-button>
<el-button v-if="canDelete" type="danger" @click="onClearAll">一键清空</el-button>
<el-button v-if="canWrite" type="success" @click="openCreate">新建订单</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<el-table
ref="tableRef"
:data="rows"
v-loading="loading"
stripe
class="w-full"
row-key="id"
@row-dblclick="openDetail"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="orderNo" label="订单号" width="150" />
<el-table-column prop="productName" label="产品" min-width="160" />
<el-table-column prop="country" label="国别" width="100" />
<el-table-column label="工厂" width="140">
<template #default="scope">
<span>{{ factoryNameById.get(scope.row.factoryId) ?? '未指定' }}</span>
</template>
</el-table-column>
<el-table-column prop="orderAmount" label="金额" width="120" />
<el-table-column prop="orderDate" label="下单" width="110" />
<el-table-column prop="customerDeliveryDate" label="客人货期" width="110" />
<el-table-column prop="orderProgress" label="进度" width="110" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="scope">
<el-button size="small" @click="openDetail(scope.row)">详情</el-button>
<el-button v-if="canWrite" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button v-if="canDelete" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination
layout="total, sizes, prev, pager, next"
:total="total"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10,20,50,100]"
@update:current-page="(v:number)=>{page=v;load()}"
@update:page-size="(v:number)=>{pageSize=v;page=1;load()}"
/>
</div>
</el-card>
<OrderFormDialog
v-model:open="dialogOpen"
:mode="dialogMode"
:factories="factories"
:model="selected"
@saved="load"
/>
<OrderImportDialog
v-model:open="importOpen"
:factories="factories"
@imported="load"
/>
</div>
</template>

66
src/router/index.ts Normal file
View File

@ -0,0 +1,66 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/components/AppLayout.vue'
import LoginPage from '@/pages/LoginPage.vue'
import DashboardPage from '@/pages/DashboardPage.vue'
import OrdersPage from '@/pages/OrdersPage.vue'
import OrderDetailPage from '@/pages/OrderDetailPage.vue'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: LoginPage,
meta: { title: '登录' },
},
{
path: '/',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: { name: 'dashboard' },
},
{
path: 'dashboard',
name: 'dashboard',
component: DashboardPage,
meta: { title: '仪表盘', requiresAuth: true },
},
{
path: 'orders',
name: 'orders',
component: OrdersPage,
meta: { title: '订单管理', requiresAuth: true },
},
{
path: 'orders/:id',
name: 'orderDetail',
component: OrderDetailPage,
meta: { title: '订单详情', requiresAuth: true },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: { name: 'dashboard' },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
await auth.hydrate()
const requiresAuth = Boolean(to.meta.requiresAuth)
if (requiresAuth && !auth.isAuthed) return { name: 'login' }
if (to.name === 'login' && auth.isAuthed) return { name: 'dashboard' }
return true
})
export default router

2
src/shared/types.ts Normal file
View File

@ -0,0 +1,2 @@
export * from '../../shared/types'

59
src/stores/auth.ts Normal file
View File

@ -0,0 +1,59 @@
import { defineStore } from 'pinia'
import type { PublicUser } from '@/shared/types'
import { api } from '@/utils/api'
type AuthState = {
token: string
user: PublicUser | null
hydrated: boolean
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
token: localStorage.getItem('token') ?? '',
user: (() => {
const raw = localStorage.getItem('user')
return raw ? (JSON.parse(raw) as PublicUser) : null
})(),
hydrated: false,
}),
getters: {
isAuthed: (s) => Boolean(s.token && s.user),
role: (s) => s.user?.role,
},
actions: {
async hydrate() {
if (this.hydrated) return
this.hydrated = true
if (!this.token) return
try {
const user = await api.me()
this.user = user
localStorage.setItem('user', JSON.stringify(user))
} catch {
this.logout()
}
},
async login(params: { username: string; password: string }) {
const res = await api.login(params)
this.token = res.token
this.user = res.user
localStorage.setItem('token', res.token)
localStorage.setItem('user', JSON.stringify(res.user))
},
async register(params: { username: string; password: string; email?: string }) {
const res = await api.register(params)
this.token = res.token
this.user = res.user
localStorage.setItem('token', res.token)
localStorage.setItem('user', JSON.stringify(res.user))
},
logout() {
this.token = ''
this.user = null
localStorage.removeItem('token')
localStorage.removeItem('user')
},
},
})

17
src/style.css Normal file
View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

143
src/utils/api.ts Normal file
View File

@ -0,0 +1,143 @@
import { http, type ApiEnvelope } from './http'
import type {
Factory,
Order,
OrderProgressEvent,
OrderProgressStatus,
PublicUser,
} from '@/shared/types'
export interface LoginResponse {
token: string
user: PublicUser
}
export const api = {
async login(params: { username: string; password: string }) {
const res = await http.post<ApiEnvelope<LoginResponse>>('/auth/login', params)
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '登录失败')
return res.data.data
},
async register(params: { username: string; password: string; email?: string }) {
const res = await http.post<ApiEnvelope<LoginResponse>>('/auth/register', params)
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '注册失败')
return res.data.data
},
async me() {
const res = await http.get<ApiEnvelope<PublicUser>>('/auth/me')
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '未登录')
return res.data.data
},
async listFactories() {
const res = await http.get<ApiEnvelope<Factory[]>>('/factories')
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '加载工厂失败')
return res.data.data
},
async listOrders(params: {
page: number
pageSize: number
orderNo?: string
poNo?: string
productName?: string
country?: string
factoryId?: string
orderProgress?: string
overdue?: boolean
dateFrom?: string
dateTo?: string
}) {
const res = await http.get<
ApiEnvelope<{ items: Order[]; total: number; page: number; pageSize: number }>
>('/orders', { params })
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '加载订单失败')
return res.data.data
},
async getOrderDetail(id: string) {
const res = await http.get<
ApiEnvelope<{ order: Order; factory?: Factory; progressEvents: OrderProgressEvent[] }>
>(`/orders/${id}`)
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '加载订单详情失败')
return res.data.data
},
async createOrder(input: Partial<Order> & { orderNo: string; productName: string }) {
const res = await http.post<ApiEnvelope<Order>>('/orders', input)
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '创建失败')
return res.data.data
},
async checkOrderDuplicates(orderNos: string[]) {
const res = await http.post<ApiEnvelope<{ duplicates: string[] }>>('/orders/duplicates', {
orderNos,
})
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '校验失败')
return res.data.data.duplicates
},
async bulkCreateOrders(params: {
items: (Partial<Order> & { orderNo: string; productName: string })[]
skipDuplicates?: boolean
}) {
const res = await http.post<
ApiEnvelope<{ createdCount: number; skippedOrderNos: string[] }>
>('/orders/bulk', params, { timeout: 60000 })
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '导入失败')
return res.data.data
},
async updateOrder(id: string, patch: Partial<Order>) {
const res = await http.put<ApiEnvelope<Order>>(`/orders/${id}`, patch)
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '更新失败')
return res.data.data
},
async deleteOrder(id: string) {
const res = await http.delete<ApiEnvelope<void>>(`/orders/${id}`)
if (!res.data.success) throw new Error(res.data.error || '删除失败')
return true
},
async deleteOrders(ids: string[]) {
const res = await http.post<ApiEnvelope<{ removed: number }>>('/orders/deleteMany', { ids })
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '批量删除失败')
return res.data.data
},
async clearOrders() {
const res = await http.post<ApiEnvelope<{ removed: number }>>('/orders/clear')
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '清空失败')
return res.data.data
},
async addProgress(id: string, input: { status: OrderProgressStatus; note?: string }) {
const res = await http.post<ApiEnvelope<OrderProgressEvent>>(`/orders/${id}/progress`, input)
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '更新进度失败')
return res.data.data
},
async dashboard() {
const res = await http.get<
ApiEnvelope<{
kpis: {
totalOrders: number
activeOrders: number
overdueOrders: number
totalAmount: number
}
charts: {
statusDist: { name: string; value: number }[]
factoryDist: { name: string; value: number }[]
trend: { date: string; amount: number; count: number }[]
}
}>
>('/stats/dashboard')
if (!res.data.success || !res.data.data) throw new Error(res.data.error || '加载仪表盘失败')
return res.data.data
},
}

14
src/utils/constants.ts Normal file
View File

@ -0,0 +1,14 @@
import type { OrderProgressStatus } from '@/shared/types'
export const ORDER_PROGRESS_OPTIONS: { label: string; value: OrderProgressStatus }[] = [
{ label: '下单', value: '下单' },
{ label: '生产', value: '生产' },
{ label: '包材唛头', value: '包材唛头' },
{ label: '外箱不干胶', value: '外箱不干胶' },
{ label: '订舱', value: '订舱' },
{ label: '验货', value: '验货' },
{ label: '出货', value: '出货' },
{ label: '完成', value: '完成' },
{ label: '取消', value: '取消' },
]

22
src/utils/http.ts Normal file
View File

@ -0,0 +1,22 @@
import axios from 'axios'
export interface ApiEnvelope<T> {
success: boolean
data?: T
error?: string
}
export const http = axios.create({
baseURL: '/api',
timeout: 15000,
})
http.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
}
return config
})

357
src/utils/orderImport.ts Normal file
View File

@ -0,0 +1,357 @@
import dayjs from 'dayjs'
import * as XLSX from 'xlsx'
import type { Factory, Order, OrderProgressStatus } from '@/shared/types'
export type ImportSeverity = 'error' | 'warn'
export interface ImportIssue {
severity: ImportSeverity
field?: keyof Order | 'factoryName' | string
message: string
}
export interface ImportPreviewRow {
index: number
selected: boolean
raw: Record<string, unknown>
cleaned: Partial<Order> & { orderNo?: string; productName?: string; factoryName?: string }
issues: ImportIssue[]
}
export interface ImportPreview {
rows: ImportPreviewRow[]
stats: {
total: number
ok: number
errors: number
warnings: number
duplicates: number
}
}
const cleanText = (v: unknown): string => {
if (v === null || v === undefined) return ''
const s = String(v)
return s.replace(/\u00A0/g, ' ').replace(/[\s\u3000]+/g, ' ').trim()
}
const cleanCodeLike = (v: unknown): string => {
if (typeof v === 'number' && Number.isFinite(v)) {
if (Number.isInteger(v)) return String(v)
const asInt = Math.trunc(v)
if (Math.abs(v - asInt) < 1e-9) return String(asInt)
return String(v)
}
return cleanText(v)
}
const parseNumberLike = (v: unknown): number | undefined => {
if (typeof v === 'number' && Number.isFinite(v)) return v
const s = cleanText(v)
if (!s) return undefined
const normalized = s.replace(/[¥$,]/g, '')
const n = Number(normalized)
return Number.isFinite(n) ? n : undefined
}
const excelDateToIso = (n: number): string | undefined => {
const d = XLSX.SSF.parse_date_code(n)
if (!d || !d.y || !d.m || !d.d) return undefined
const yyyy = String(d.y).padStart(4, '0')
const mm = String(d.m).padStart(2, '0')
const dd = String(d.d).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
const parseDateLike = (v: unknown): string | undefined => {
if (v instanceof Date && !Number.isNaN(v.getTime())) {
return dayjs(v).format('YYYY-MM-DD')
}
if (typeof v === 'number' && Number.isFinite(v)) {
return excelDateToIso(v)
}
const s = cleanText(v)
if (!s) return undefined
const s2 = s.replace(/[./]/g, '-').replace(/\s+/g, ' ').trim()
const datePart = s2.split(' ')[0]
const m = datePart.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/)
if (m) {
const y = Number(m[1])
const mo = Number(m[2])
const d = Number(m[3])
if (Number.isFinite(y) && Number.isFinite(mo) && Number.isFinite(d)) {
const dt = new Date(y, mo - 1, d)
if (dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d) {
return `${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}
}
}
const relaxed = dayjs(datePart)
return relaxed.isValid() ? relaxed.format('YYYY-MM-DD') : undefined
}
const normalizeHeader = (h: string): string => cleanText(h).replace(/[:]/g, '').toLowerCase()
const toProgressStatus = (s: string): string | undefined => {
if (!s) return undefined
const normalized = s.replace(/[\s-_/]/g, '')
const candidates: OrderProgressStatus[] = [
'下单',
'生产',
'包材唛头',
'外箱不干胶',
'订舱',
'验货',
'出货',
'完成',
'取消',
]
for (const c of candidates) {
if (normalized === c) return c
}
const alias: Record<string, OrderProgressStatus> = {
: '下单',
: '下单',
: '生产',
: '包材唛头',
: '包材唛头',
: '外箱不干胶',
: '外箱不干胶',
: '订舱',
: '订舱',
: '验货',
: '出货',
: '出货',
: '出货',
: '出货',
: '完成',
: '完成',
: '生产',
: '生产',
: '生产',
: '验货',
: '验货',
: '出货',
: '完成',
: '完成',
: '取消',
}
return alias[normalized]
}
const normalizeFactoryToken = (name: string): string => {
const s = cleanText(name)
if (!s) return ''
const left = s.split(/\(|/)[0].trim()
return left.replace(/[A-Za-z]+\d+$/g, '').trim()
}
const splitFactories = (v: unknown): string[] => {
const s = cleanText(v)
if (!s) return []
return s
.split(/[,;/]/)
.map((x) => normalizeFactoryToken(x))
.filter((x) => x.length > 0)
}
const headerAliases: Record<string, keyof Order | 'factoryName'> = {
: 'orderNo',
: 'orderNo',
orderno: 'orderNo',
: 'orderNo',
po号: 'poNo',
po: 'poNo',
pono: 'poNo',
: 'productName',
: 'productName',
product: 'productName',
productname: 'productName',
: 'country',
: 'country',
country: 'country',
: 'orderAmount',
: 'orderAmount',
orderamount: 'orderAmount',
: 'orderDate',
: 'orderDate',
orderdate: 'orderDate',
: 'customerDeliveryDate',
: 'customerDeliveryDate',
customerdeliverydate: 'customerDeliveryDate',
id: 'factoryId',
factoryid: 'factoryId',
: 'factoryName',
: 'factoryName',
factory: 'factoryName',
: 'factoryDeliveryDate',
: 'factoryDeliveryDate',
factorydeliverydate: 'factoryDeliveryDate',
: 'factoryContract',
factorycontract: 'factoryContract',
: 'factoryContract',
contractstatus: 'factoryContract',
: 'packagingStatus',
: 'packagingStatus',
: 'packagingStatus',
packagingstatus: 'packagingStatus',
: 'stickerStatus',
: 'stickerStatus',
stickerstatus: 'stickerStatus',
: 'shippingStatus',
shippingstatus: 'shippingStatus',
: 'inspectionStatus',
inspectionstatus: 'inspectionStatus',
: 'purchaseAmount',
purchaseamount: 'purchaseAmount',
: 'orderProgress',
: 'orderProgress',
orderprogress: 'orderProgress',
: 'remarks',
remarks: 'remarks',
ci金额: 'ciAmount',
ciamount: 'ciAmount',
etd: 'etd',
eta: 'eta',
: 'paymentDate',
: 'paymentDate',
paymentdate: 'paymentDate',
: 'paymentAmount',
paymentamount: 'paymentAmount',
: 'balance',
balance: 'balance',
}
const remapRow = (raw: Record<string, unknown>): Record<string, unknown> => {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(raw)) {
const nk = normalizeHeader(k)
const mapped = headerAliases[nk]
if (mapped) {
out[mapped] = v
continue
}
out[nk] = v
}
return out
}
export const readSpreadsheetRows = async (file: File): Promise<Record<string, unknown>[]> => {
const ab = await file.arrayBuffer()
const wb = XLSX.read(ab, { type: 'array', cellDates: true })
const sheetName = wb.SheetNames[0]
const sheet = wb.Sheets[sheetName]
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, {
defval: '',
raw: true,
})
return rows
}
export const buildImportPreview = (args: {
rawRows: Record<string, unknown>[]
factories: Factory[]
existingOrderNos?: Set<string>
}): ImportPreview => {
const factoryByName = new Map(
args.factories.map((f) => [cleanText(f.name).toLowerCase(), f]),
)
const existing = args.existingOrderNos ?? new Set<string>()
let duplicates = 0
const rows: ImportPreviewRow[] = args.rawRows.map((raw, i) => {
const mapped = remapRow(raw)
const issues: ImportIssue[] = []
const cleaned: ImportPreviewRow['cleaned'] = {}
const orderNo = cleanCodeLike(mapped.orderNo)
const productName = cleanText(mapped.productName)
cleaned.orderNo = orderNo || undefined
cleaned.poNo = cleanCodeLike(mapped.poNo) || undefined
cleaned.productName = productName || undefined
cleaned.country = cleanText(mapped.country) || undefined
cleaned.orderAmount = parseNumberLike(mapped.orderAmount)
cleaned.orderDate = parseDateLike(mapped.orderDate)
cleaned.customerDeliveryDate = parseDateLike(mapped.customerDeliveryDate)
cleaned.factoryDeliveryDate = parseDateLike(mapped.factoryDeliveryDate)
cleaned.etd = parseDateLike(mapped.etd)
cleaned.eta = parseDateLike(mapped.eta)
cleaned.paymentDate = parseDateLike(mapped.paymentDate)
cleaned.factoryContract = cleanText(mapped.factoryContract) || undefined
cleaned.packagingStatus = cleanText(mapped.packagingStatus) || undefined
cleaned.stickerStatus = cleanText(mapped.stickerStatus) || undefined
cleaned.shippingStatus = cleanText(mapped.shippingStatus) || undefined
cleaned.inspectionStatus = cleanText(mapped.inspectionStatus) || undefined
cleaned.purchaseAmount = parseNumberLike(mapped.purchaseAmount)
cleaned.ciAmount = parseNumberLike(mapped.ciAmount)
cleaned.paymentAmount = parseNumberLike(mapped.paymentAmount)
cleaned.balance = parseNumberLike(mapped.balance)
cleaned.remarks = cleanText(mapped.remarks) || undefined
const progressText = cleanText(mapped.orderProgress)
const progress = toProgressStatus(progressText)
if (progress) {
cleaned.orderProgress = progress
} else if (progressText) {
cleaned.orderProgress = progressText
issues.push({ severity: 'warn', field: 'orderProgress', message: `非标准进度,将原样导入:${progressText}` })
}
const factoryIdRaw = cleanText(mapped.factoryId)
if (factoryIdRaw) {
cleaned.factoryId = factoryIdRaw
} else {
const factoryTokens = splitFactories(mapped.factoryName)
const firstName = factoryTokens[0] ?? ''
cleaned.factoryName = firstName || undefined
if (factoryTokens.length > 1) {
issues.push({ severity: 'warn', field: 'factoryName', message: `检测到多个工厂,已取第一个:${firstName}` })
}
if (firstName) {
const f = factoryByName.get(firstName.toLowerCase())
if (f) {
cleaned.factoryId = f.id
} else {
issues.push({ severity: 'warn', field: 'factoryName', message: `未匹配到工厂:${firstName}` })
}
}
}
if (!cleaned.orderNo) issues.push({ severity: 'error', field: 'orderNo', message: '缺少订单号' })
if (!cleaned.productName) issues.push({ severity: 'error', field: 'productName', message: '缺少产品名称' })
if (cleaned.orderNo && existing.has(cleaned.orderNo)) {
duplicates += 1
issues.push({ severity: 'warn', field: 'orderNo', message: '订单号已存在(默认将跳过)' })
}
const hasErrors = issues.some((x) => x.severity === 'error')
const selected = !hasErrors
return {
index: i + 1,
selected,
raw,
cleaned,
issues,
}
})
const errors = rows.reduce((acc, r) => acc + (r.issues.some((x) => x.severity === 'error') ? 1 : 0), 0)
const warnings = rows.reduce((acc, r) => acc + r.issues.filter((x) => x.severity === 'warn').length, 0)
const ok = rows.length - errors
return {
rows,
stats: {
total: rows.length,
ok,
errors,
warnings,
duplicates,
},
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
tailwind.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,vue}"],
theme: {
container: {
center: true,
},
extend: {},
},
plugins: [],
};

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": "./",
"paths": {
"@/*": [
"./src/*"
]
},
"types": [
"node",
"express"
]
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"api"
]
}

12
vercel.json Normal file
View File

@ -0,0 +1,12 @@
{
"rewrites": [
{
"source": "/api/(.*)",
"destination": "/api/index"
},
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}

25
vite.config.ts Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
build: {
sourcemap: 'hidden',
},
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'), // ✅ 定义 @ = src
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
}
},
},
})