first commit
This commit is contained in:
commit
41a2ebb997
31
.eslintrc.cjs
Normal file
31
.eslintrc.cjs
Normal 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
25
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
44
README.md
Normal file
44
README.md
Normal 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
73
api/app.ts
Normal 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
41
api/auth.ts
Normal 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
464
api/data/db.json
Normal 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 CT562,P13",
|
||||||
|
"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": "BHC083(ZYB096系列)",
|
||||||
|
"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": "BHC082(ZYB094系列)",
|
||||||
|
"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": "BHC081(ZYB095系列)",
|
||||||
|
"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
457
api/db.ts
Normal 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
9
api/index.ts
Normal 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);
|
||||||
|
}
|
||||||
46
api/middleware/requireAuth.ts
Normal file
46
api/middleware/requireAuth.ts
Normal 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
96
api/routes/auth.ts
Normal 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
15
api/routes/factories.ts
Normal 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
463
api/routes/orders.ts
Normal 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
123
api/routes/stats.ts
Normal 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
34
api/server.ts
Normal 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
24
index.html
Normal 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
10
nodemon.json
Normal 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
9222
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
package.json
Normal file
60
package.json
Normal 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
10
postcss.config.js
Normal 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
4
public/favicon.svg
Normal 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
80
shared/types.ts
Normal 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
3
src/App.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal 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 |
48
src/components/AppLayout.vue
Normal file
48
src/components/AppLayout.vue
Normal 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
3
src/components/Empty.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div>empty</div>
|
||||||
|
</template>
|
||||||
47
src/components/SideNav.vue
Normal file
47
src/components/SideNav.vue
Normal 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>
|
||||||
|
|
||||||
41
src/components/charts/EChart.vue
Normal file
41
src/components/charts/EChart.vue
Normal 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>
|
||||||
|
|
||||||
149
src/components/orders/OrderFormDialog.vue
Normal file
149
src/components/orders/OrderFormDialog.vue
Normal 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>
|
||||||
|
|
||||||
408
src/components/orders/OrderImportDialog.vue
Normal file
408
src/components/orders/OrderImportDialog.vue
Normal 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>
|
||||||
|
|
||||||
40
src/composables/useTheme.ts
Normal file
40
src/composables/useTheme.ts
Normal 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
6
src/lib/utils.ts
Normal 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
18
src/main.ts
Normal 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
130
src/pages/DashboardPage.vue
Normal 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
6
src/pages/HomePage.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
81
src/pages/LoginPage.vue
Normal file
81
src/pages/LoginPage.vue
Normal 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>
|
||||||
|
|
||||||
141
src/pages/OrderDetailPage.vue
Normal file
141
src/pages/OrderDetailPage.vue
Normal 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
275
src/pages/OrdersPage.vue
Normal 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
66
src/router/index.ts
Normal 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
2
src/shared/types.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from '../../shared/types'
|
||||||
|
|
||||||
59
src/stores/auth.ts
Normal file
59
src/stores/auth.ts
Normal 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
17
src/style.css
Normal 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
143
src/utils/api.ts
Normal 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
14
src/utils/constants.ts
Normal 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
22
src/utils/http.ts
Normal 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
357
src/utils/orderImport.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
13
tailwind.config.js
Normal file
13
tailwind.config.js
Normal 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
26
tsconfig.json
Normal 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
12
vercel.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/api/(.*)",
|
||||||
|
"destination": "/api/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
25
vite.config.ts
Normal file
25
vite.config.ts
Normal 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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user