3136 lines
84 KiB
Markdown
3136 lines
84 KiB
Markdown
# WMMS Vue 重构实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 将 WMMS 前端从 jQuery 迁移到 Vue 3,完成 8 个页面的功能重构
|
||
|
||
**Architecture:** 前后端分离架构,Vue 3 SPA 使用 Vite 构建,Pinia 状态管理,Element Plus UI 组件库,Vue Router History 模式
|
||
|
||
**Tech Stack:** Vue 3 (Composition API) + Vite + Pinia + Vue Router 4 + Element Plus + Axios
|
||
|
||
**Spec:** docs/superpowers/specs/2026-09-07-wmms-vue-migration-design.md
|
||
|
||
---
|
||
|
||
## Global Constraints
|
||
|
||
- Vue 3.4+ (Composition API)
|
||
- Vite 5.x
|
||
- Pinia 2.x
|
||
- Vue Router 4.x
|
||
- Element Plus 2.x
|
||
- Axios 1.x
|
||
- 开发端口:8081
|
||
- 代理 API 到 localhost:8080
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
```
|
||
frontend/
|
||
├── src/
|
||
│ ├── main.js # 应用入口
|
||
│ ├── App.vue # 根组件
|
||
│ ├── api/
|
||
│ │ ├── index.js # Axios 实例
|
||
│ │ ├── auth.js # 认证 API
|
||
│ │ ├── materials.js # 物料 API
|
||
│ │ ├── inbound.js # 入库 API
|
||
│ │ ├── outbound.js # 出库 API
|
||
│ │ ├── stock.js # 库存 API
|
||
│ │ ├── application.js # 申请 API
|
||
│ │ ├── logs.js # 日志 API
|
||
│ │ └── users.js # 用户 API
|
||
│ ├── stores/
|
||
│ │ ├── auth.js # 认证 Store
|
||
│ │ ├── materials.js # 物料 Store
|
||
│ │ ├── stock.js # 库存 Store
|
||
│ │ ├── inbound.js # 入库 Store
|
||
│ │ ├── outbound.js # 出库 Store
|
||
│ │ ├── application.js # 申请 Store
|
||
│ │ ├── approval.js # 审批 Store
|
||
│ │ ├── logs.js # 日志 Store
|
||
│ │ ├── users.js # 用户 Store
|
||
│ │ └── ui.js # UI 状态 Store
|
||
│ ├── router/
|
||
│ │ └── index.js # 路由配置 + 守卫
|
||
│ ├── components/
|
||
│ │ ├── layout/
|
||
│ │ │ ├── AppHeader.vue # 顶部导航
|
||
│ │ │ └── AppSidebar.vue # 侧边菜单
|
||
│ │ └── business/
|
||
│ │ ├── BaseTable.vue # 通用表格
|
||
│ │ ├── BasePagination.vue # 分页组件
|
||
│ │ ├── BaseModal.vue # 弹窗组件
|
||
│ │ ├── StatusBadge.vue # 状态徽章
|
||
│ │ ├── RoleBadge.vue # 角色徽章
|
||
│ │ ├── DeleteConfirm.vue # 删除确认
|
||
│ │ ├── AlertTip.vue # 提示消息
|
||
│ │ └── LoadingOverlay.vue # 加载遮罩
|
||
│ └── views/
|
||
│ ├── Login.vue # 登录页
|
||
│ ├── Materials.vue # 物料管理
|
||
│ ├── Inbound.vue # 入库管理
|
||
│ ├── Outbound.vue # 出库管理
|
||
│ ├── Stock.vue # 库存查询
|
||
│ ├── Application.vue # 物料申请
|
||
│ ├── Approval.vue # 申请审批
|
||
│ ├── Logs.vue # 日志记录
|
||
│ └── Users.vue # 用户管理
|
||
├── index.html
|
||
├── vite.config.js
|
||
└── package.json
|
||
```
|
||
|
||
---
|
||
|
||
## Day 1: 项目初始化 + API 层 + Pinia Store
|
||
|
||
### Task 1: 初始化 Vue 项目
|
||
|
||
**Files:**
|
||
- Create: `frontend/package.json`
|
||
- Create: `frontend/vite.config.js`
|
||
- Create: `frontend/index.html`
|
||
- Create: `frontend/src/main.js`
|
||
- Create: `frontend/src/App.vue`
|
||
|
||
**Interfaces:**
|
||
- Produces: 可运行的 Vue 3 + Vite 项目,端口 8081,代理 /api 到 localhost:8080
|
||
|
||
- [ ] **Step 1: 创建 package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "wmms-frontend",
|
||
"version": "1.0.0",
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vite build",
|
||
"preview": "vite preview"
|
||
},
|
||
"dependencies": {
|
||
"vue": "^3.4.0",
|
||
"vue-router": "^4.2.0",
|
||
"pinia": "^2.1.0",
|
||
"element-plus": "^2.5.0",
|
||
"axios": "^1.6.0"
|
||
},
|
||
"devDependencies": {
|
||
"@vitejs/plugin-vue": "^5.0.0",
|
||
"vite": "^5.0.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 vite.config.js**
|
||
|
||
```js
|
||
import { defineConfig } from 'vite'
|
||
import vue from '@vitejs/plugin-vue'
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
server: {
|
||
port: 8081,
|
||
proxy: {
|
||
'/api': {
|
||
target: 'http://localhost:8080',
|
||
changeOrigin: true
|
||
},
|
||
'/ws': {
|
||
target: 'ws://localhost:8080',
|
||
ws: true
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 index.html**
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>仓库物料管理系统</title>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
<script type="module" src="/src/main.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 main.js**
|
||
|
||
```js
|
||
import { createApp } from 'vue'
|
||
import { createPinia } from 'pinia'
|
||
import ElementPlus from 'element-plus'
|
||
import 'element-plus/dist/index.css'
|
||
import App from './App.vue'
|
||
import router from './router'
|
||
|
||
const app = createApp(App)
|
||
app.use(createPinia())
|
||
app.use(router)
|
||
app.use(ElementPlus)
|
||
app.mount('#app')
|
||
```
|
||
|
||
- [ ] **Step 5: 创建 App.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<router-view />
|
||
</template>
|
||
|
||
<script setup>
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 6: 创建基础路由文件 router/index.js**
|
||
|
||
```js
|
||
import { createRouter, createWebHistory } from 'vue-router'
|
||
|
||
const routes = []
|
||
|
||
const router = createRouter({
|
||
history: createWebHistory(),
|
||
routes
|
||
})
|
||
|
||
export default router
|
||
```
|
||
|
||
- [ ] **Step 7: 安装依赖并验证项目运行**
|
||
|
||
Run: `cd frontend && npm install && npm run dev`
|
||
Expected: 访问 localhost:8081 显示空白页面,无报错
|
||
|
||
---
|
||
|
||
### Task 2: 创建 Axios 实例和 API 层
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/api/index.js`
|
||
- Create: `frontend/src/api/auth.js`
|
||
- Create: `frontend/src/api/materials.js`
|
||
- Create: `frontend/src/api/inbound.js`
|
||
- Create: `frontend/src/api/outbound.js`
|
||
- Create: `frontend/src/api/stock.js`
|
||
- Create: `frontend/src/api/application.js`
|
||
- Create: `frontend/src/api/logs.js`
|
||
- Create: `frontend/src/api/users.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: 无
|
||
- Produces: Axios 实例,模块化 API 函数,供 Store 调用
|
||
|
||
- [ ] **Step 1: 创建 api/index.js (Axios 实例)**
|
||
|
||
```js
|
||
import axios from 'axios'
|
||
import { ElMessage } from 'element-plus'
|
||
import router from '../router'
|
||
|
||
const request = axios.create({
|
||
baseURL: '/api',
|
||
timeout: 30000,
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
// 请求拦截器
|
||
request.interceptors.request.use(
|
||
(config) => {
|
||
const token = localStorage.getItem('wmms_token')
|
||
if (token) {
|
||
config.headers.Authorization = `Bearer ${token}`
|
||
}
|
||
return config
|
||
},
|
||
(error) => Promise.reject(error)
|
||
)
|
||
|
||
// 响应拦截器
|
||
request.interceptors.response.use(
|
||
(response) => {
|
||
const res = response.data
|
||
if (res.code === 0) {
|
||
return res
|
||
}
|
||
ElMessage.error(res.message || '请求失败')
|
||
return Promise.reject(new Error(res.message))
|
||
},
|
||
(error) => {
|
||
if (error.response?.status === 401) {
|
||
localStorage.removeItem('wmms_token')
|
||
localStorage.removeItem('wmms_user')
|
||
router.push('/login')
|
||
ElMessage.error('登录已过期,请重新登录')
|
||
} else {
|
||
ElMessage.error(error.message || '网络错误')
|
||
}
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
export default request
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 api/auth.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const authApi = {
|
||
login(username, password) {
|
||
return request.post('/auth/login', { username, password })
|
||
},
|
||
getCurrentUser() {
|
||
return request.get('/auth/me')
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 api/materials.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const materialsApi = {
|
||
getAll(params = {}) {
|
||
return request.get('/materials', { params })
|
||
},
|
||
getById(id) {
|
||
return request.get(`/materials/${id}`)
|
||
},
|
||
create(data) {
|
||
return request.post('/materials', data)
|
||
},
|
||
update(id, data) {
|
||
return request.put(`/materials/${id}`, data)
|
||
},
|
||
delete(id) {
|
||
return request.delete(`/materials/${id}`)
|
||
},
|
||
uploadImage(formData) {
|
||
return request.post('/upload', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' }
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 api/inbound.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const inboundApi = {
|
||
getAll(params = {}) {
|
||
return request.get('/inbound', { params })
|
||
},
|
||
getById(id) {
|
||
return request.get(`/inbound/${id}`)
|
||
},
|
||
create(data) {
|
||
return request.post('/inbound', data)
|
||
},
|
||
update(id, data) {
|
||
return request.put(`/inbound/${id}`, data)
|
||
},
|
||
delete(id) {
|
||
return request.delete(`/inbound/${id}`)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 创建 api/outbound.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const outboundApi = {
|
||
getAll(params = {}) {
|
||
return request.get('/outbound', { params })
|
||
},
|
||
getById(id) {
|
||
return request.get(`/outbound/${id}`)
|
||
},
|
||
create(data) {
|
||
return request.post('/outbound', data)
|
||
},
|
||
update(id, data) {
|
||
return request.put(`/outbound/${id}`, data)
|
||
},
|
||
delete(id) {
|
||
return request.delete(`/outbound/${id}`)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 创建 api/stock.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const stockApi = {
|
||
getAll(params = {}) {
|
||
return request.get('/stock', { params })
|
||
},
|
||
getWarning(params = {}) {
|
||
return request.get('/stock/warning', { params })
|
||
},
|
||
getLogs(materialId, params = {}) {
|
||
return request.get(`/stock/logs/${materialId}`, { params })
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 创建 api/application.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const applicationApi = {
|
||
create(data) {
|
||
return request.post('/applications', data)
|
||
},
|
||
getMy(status = '', page = 1, pageSize = 20) {
|
||
return request.get('/applications/my', {
|
||
params: { status, page, pageSize }
|
||
})
|
||
},
|
||
getAll(status = '', page = 1, pageSize = 20) {
|
||
return request.get('/applications', {
|
||
params: { status, page, pageSize }
|
||
})
|
||
},
|
||
cancel(id) {
|
||
return request.post(`/applications/${id}/cancel`, {})
|
||
},
|
||
approve(id, data) {
|
||
return request.post(`/applications/${id}/approve`, data)
|
||
},
|
||
reject(id, data) {
|
||
return request.post(`/applications/${id}/reject`, data)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: 创建 api/logs.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const logsApi = {
|
||
getAll(params = {}) {
|
||
return request.get('/logs', { params })
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: 创建 api/users.js**
|
||
|
||
```js
|
||
import request from './index'
|
||
|
||
export const usersApi = {
|
||
getAll(params = {}) {
|
||
return request.get('/users', { params })
|
||
},
|
||
create(data) {
|
||
return request.post('/users', data)
|
||
},
|
||
update(id, data) {
|
||
return request.put(`/users/${id}`, data)
|
||
},
|
||
delete(id) {
|
||
return request.delete(`/users/${id}`)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: 验证 API 层可正常调用**
|
||
|
||
Run: `cd frontend && npm run dev`,在浏览器控制台测试 API 调用
|
||
Expected: 无报错,API 能正常请求到后端
|
||
|
||
---
|
||
|
||
### Task 3: 创建 Pinia Store
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/stores/auth.js`
|
||
- Create: `frontend/src/stores/materials.js`
|
||
- Create: `frontend/src/stores/stock.js`
|
||
- Create: `frontend/src/stores/inbound.js`
|
||
- Create: `frontend/src/stores/outbound.js`
|
||
- Create: `frontend/src/stores/application.js`
|
||
- Create: `frontend/src/stores/approval.js`
|
||
- Create: `frontend/src/stores/logs.js`
|
||
- Create: `frontend/src/stores/users.js`
|
||
- Create: `frontend/src/stores/ui.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: API 层函数
|
||
- Produces: 各模块的响应式状态和 actions
|
||
|
||
- [ ] **Step 1: 创建 stores/auth.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref, computed } from 'vue'
|
||
import { authApi } from '../api/auth'
|
||
|
||
export const useAuthStore = defineStore('auth', () => {
|
||
const token = ref(localStorage.getItem('wmms_token') || '')
|
||
const user = ref(JSON.parse(localStorage.getItem('wmms_user') || 'null'))
|
||
|
||
const isLoggedIn = computed(() => !!token.value)
|
||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||
|
||
function saveAuth(newToken, newUser) {
|
||
token.value = newToken
|
||
user.value = newUser
|
||
localStorage.setItem('wmms_token', newToken)
|
||
localStorage.setItem('wmms_user', JSON.stringify(newUser))
|
||
}
|
||
|
||
function clearAuth() {
|
||
token.value = ''
|
||
user.value = null
|
||
localStorage.removeItem('wmms_token')
|
||
localStorage.removeItem('wmms_user')
|
||
}
|
||
|
||
async function login(username, password) {
|
||
const res = await authApi.login(username, password)
|
||
saveAuth(res.data.token, res.data.user)
|
||
return res.data
|
||
}
|
||
|
||
async function fetchCurrentUser() {
|
||
if (!token.value) return null
|
||
try {
|
||
const res = await authApi.getCurrentUser()
|
||
user.value = res.data
|
||
return res.data
|
||
} catch {
|
||
clearAuth()
|
||
return null
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
clearAuth()
|
||
}
|
||
|
||
return {
|
||
token, user, isLoggedIn, isAdmin,
|
||
saveAuth, clearAuth, login, fetchCurrentUser, logout
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 stores/materials.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { materialsApi } from '../api/materials'
|
||
|
||
export const useMaterialsStore = defineStore('materials', () => {
|
||
const materials = ref([])
|
||
const loading = ref(false)
|
||
const pagination = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
|
||
const categoryMap = {
|
||
'A01': '标准件', 'A02': '成品件', 'A03': '简易加工件', 'A04': '精密加工件',
|
||
'A05': '钣金件', 'A06': '塑胶件', 'A07': '其他',
|
||
'B01': '成品件', 'B02': '简易加工件', 'B03': 'PCB硬板', 'B04': 'FPC软板',
|
||
'B05': '线束', 'B06': '组装组件', 'B07': '其他',
|
||
'C01': '连接耗材', 'C02': '密封耗材', 'C03': '其他',
|
||
'D01': '自研程序固件', 'D02': '外购软件授权', 'D03': '授权介质', 'D04': '其他',
|
||
'E01': '成品', 'E02': '半成品',
|
||
'F01': '资料文档', 'F02': '标识标牌', 'F03': '包装物料', 'F04': '随机工具配件', 'F05': '其他'
|
||
}
|
||
|
||
function getCategoryDisplay(category) {
|
||
if (!category) return '-'
|
||
const name = categoryMap[category] || ''
|
||
return name ? `${category} - ${name}` : category
|
||
}
|
||
|
||
async function fetchMaterials(page = 1, search = '') {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = pagination.value.pageSize
|
||
const offset = (page - 1) * pageSize
|
||
const res = await materialsApi.getAll({ search, limit: pageSize, offset })
|
||
materials.value = res.data?.items || res.data || []
|
||
pagination.value.total = res.data?.total || materials.value.length
|
||
pagination.value.currentPage = page
|
||
pagination.value.totalPages = Math.ceil(pagination.value.total / pageSize) || 1
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function createMaterial(data) {
|
||
const res = await materialsApi.create(data)
|
||
await fetchMaterials(1)
|
||
return res
|
||
}
|
||
|
||
async function updateMaterial(id, data) {
|
||
const res = await materialsApi.update(id, data)
|
||
await fetchMaterials(pagination.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
async function deleteMaterial(id) {
|
||
const res = await materialsApi.delete(id)
|
||
await fetchMaterials(pagination.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
return {
|
||
materials, loading, pagination, categoryMap,
|
||
getCategoryDisplay, fetchMaterials, createMaterial, updateMaterial, deleteMaterial
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 stores/stock.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { stockApi } from '../api/stock'
|
||
|
||
export const useStockStore = defineStore('stock', () => {
|
||
const stockList = ref([])
|
||
const warningList = ref([])
|
||
const logs = ref([])
|
||
const loading = ref(false)
|
||
const stockPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
const warningPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
const logsPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
|
||
async function fetchStock(page = 1) {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = stockPage.value.pageSize
|
||
const offset = (page - 1) * pageSize
|
||
const res = await stockApi.getAll({ limit: pageSize, offset })
|
||
stockList.value = res.data?.items || res.data || []
|
||
stockPage.value.total = res.data?.total || stockList.value.length
|
||
stockPage.value.currentPage = page
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function fetchStockWarning(page = 1) {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = warningPage.value.pageSize
|
||
const offset = (page - 1) * pageSize
|
||
const res = await stockApi.getWarning({ limit: pageSize, offset })
|
||
warningList.value = res.data?.items || res.data || []
|
||
warningPage.value.total = res.data?.total || warningList.value.length
|
||
warningPage.value.currentPage = page
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function fetchStockLogs(materialId, page = 1) {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = logsPage.value.pageSize
|
||
const offset = (page - 1) * pageSize
|
||
const res = await stockApi.getLogs(materialId, { limit: pageSize, offset })
|
||
logs.value = res.data?.items || res.data || []
|
||
logsPage.value.total = res.data?.total || logs.value.length
|
||
logsPage.value.currentPage = page
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
return {
|
||
stockList, warningList, logs, loading,
|
||
stockPage, warningPage, logsPage,
|
||
fetchStock, fetchStockWarning, fetchStockLogs
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 stores/inbound.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { inboundApi } from '../api/inbound'
|
||
|
||
export const useInboundStore = defineStore('inbound', () => {
|
||
const inboundList = ref([])
|
||
const loading = ref(false)
|
||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
|
||
async function fetchInboundList(pageNum = 1) {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = page.value.pageSize
|
||
const offset = (pageNum - 1) * pageSize
|
||
const res = await inboundApi.getAll({ limit: pageSize, offset })
|
||
inboundList.value = res.data?.items || res.data || []
|
||
page.value.total = res.data?.total || inboundList.value.length
|
||
page.value.currentPage = pageNum
|
||
page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function createInbound(data) {
|
||
const res = await inboundApi.create(data)
|
||
await fetchInboundList(1)
|
||
return res
|
||
}
|
||
|
||
async function updateInbound(id, data) {
|
||
const res = await inboundApi.update(id, data)
|
||
await fetchInboundList(page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
async function deleteInbound(id) {
|
||
const res = await inboundApi.delete(id)
|
||
await fetchInboundList(page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
return {
|
||
inboundList, loading, page,
|
||
fetchInboundList, createInbound, updateInbound, deleteInbound
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 5: 创建 stores/outbound.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { outboundApi } from '../api/outbound'
|
||
|
||
export const useOutboundStore = defineStore('outbound', () => {
|
||
const outboundList = ref([])
|
||
const loading = ref(false)
|
||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
|
||
async function fetchOutboundList(pageNum = 1) {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = page.value.pageSize
|
||
const offset = (pageNum - 1) * pageSize
|
||
const res = await outboundApi.getAll({ limit: pageSize, offset })
|
||
outboundList.value = res.data?.items || res.data || []
|
||
page.value.total = res.data?.total || outboundList.value.length
|
||
page.value.currentPage = pageNum
|
||
page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function createOutbound(data) {
|
||
const res = await outboundApi.create(data)
|
||
await fetchOutboundList(1)
|
||
return res
|
||
}
|
||
|
||
async function updateOutbound(id, data) {
|
||
const res = await outboundApi.update(id, data)
|
||
await fetchOutboundList(page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
async function deleteOutbound(id) {
|
||
const res = await outboundApi.delete(id)
|
||
await fetchOutboundList(page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
return {
|
||
outboundList, loading, page,
|
||
fetchOutboundList, createOutbound, updateOutbound, deleteOutbound
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: 创建 stores/application.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { applicationApi } from '../api/application'
|
||
|
||
export const useApplicationStore = defineStore('application', () => {
|
||
const myApplications = ref([])
|
||
const loading = ref(false)
|
||
const myPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
|
||
async function fetchMyApplications(status = '', pageNum = 1) {
|
||
loading.value = true
|
||
try {
|
||
const res = await applicationApi.getMy(status, pageNum, myPage.value.pageSize)
|
||
myApplications.value = res.data?.items || res.data || []
|
||
myPage.value.total = res.data?.total || myApplications.value.length
|
||
myPage.value.currentPage = pageNum
|
||
myPage.value.totalPages = Math.ceil(myPage.value.total / myPage.value.pageSize) || 1
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function createApplication(data) {
|
||
const res = await applicationApi.create(data)
|
||
await fetchMyApplications('', 1)
|
||
return res
|
||
}
|
||
|
||
async function cancelApplication(id) {
|
||
const res = await applicationApi.cancel(id)
|
||
await fetchMyApplications('', myPage.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
return {
|
||
myApplications, loading, myPage,
|
||
fetchMyApplications, createApplication, cancelApplication
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 7: 创建 stores/approval.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { applicationApi } from '../api/application'
|
||
|
||
export const useApprovalStore = defineStore('approval', () => {
|
||
const applications = ref([])
|
||
const loading = ref(false)
|
||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
|
||
async function fetchPendingApplications(status = '', pageNum = 1) {
|
||
loading.value = true
|
||
try {
|
||
const res = await applicationApi.getAll(status, pageNum, page.value.pageSize)
|
||
applications.value = res.data?.items || res.data || []
|
||
page.value.total = res.data?.total || applications.value.length
|
||
page.value.currentPage = pageNum
|
||
page.value.totalPages = Math.ceil(page.value.total / page.value.pageSize) || 1
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function approveApplication(id, data = {}) {
|
||
const res = await applicationApi.approve(id, data)
|
||
await fetchPendingApplications('', page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
async function rejectApplication(id, data = {}) {
|
||
const res = await applicationApi.reject(id, data)
|
||
await fetchPendingApplications('', page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
return {
|
||
applications, loading, page,
|
||
fetchPendingApplications, approveApplication, rejectApplication
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 8: 创建 stores/logs.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { logsApi } from '../api/logs'
|
||
|
||
export const useLogsStore = defineStore('logs', () => {
|
||
const logs = ref([])
|
||
const loading = ref(false)
|
||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
const filters = ref({ operationType: '', startDate: '', endDate: '' })
|
||
|
||
async function fetchLogs(pageNum = 1) {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = page.value.pageSize
|
||
const offset = (pageNum - 1) * pageSize
|
||
const params = { limit: pageSize, offset, ...filters.value }
|
||
const res = await logsApi.getAll(params)
|
||
logs.value = res.data?.items || res.data || []
|
||
page.value.total = res.data?.total || logs.value.length
|
||
page.value.currentPage = pageNum
|
||
page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function setFilters(newFilters) {
|
||
filters.value = { ...filters.value, ...newFilters }
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.value = { operationType: '', startDate: '', endDate: '' }
|
||
}
|
||
|
||
return {
|
||
logs, loading, page, filters,
|
||
fetchLogs, setFilters, resetFilters
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 9: 创建 stores/users.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { usersApi } from '../api/users'
|
||
|
||
export const useUsersStore = defineStore('users', () => {
|
||
const users = ref([])
|
||
const loading = ref(false)
|
||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
|
||
async function fetchUsers(pageNum = 1) {
|
||
loading.value = true
|
||
try {
|
||
const pageSize = page.value.pageSize
|
||
const offset = (pageNum - 1) * pageSize
|
||
const res = await usersApi.getAll({ limit: pageSize, offset })
|
||
users.value = res.data?.items || res.data || []
|
||
page.value.total = res.data?.total || users.value.length
|
||
page.value.currentPage = pageNum
|
||
page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function createUser(data) {
|
||
const res = await usersApi.create(data)
|
||
await fetchUsers(1)
|
||
return res
|
||
}
|
||
|
||
async function updateUser(id, data) {
|
||
const res = await usersApi.update(id, data)
|
||
await fetchUsers(page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
async function deleteUser(id) {
|
||
const res = await usersApi.delete(id)
|
||
await fetchUsers(page.value.currentPage)
|
||
return res
|
||
}
|
||
|
||
return {
|
||
users, loading, page,
|
||
fetchUsers, createUser, updateUser, deleteUser
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 10: 创建 stores/ui.js**
|
||
|
||
```js
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
|
||
export const useUiStore = defineStore('ui', () => {
|
||
const loading = ref(false)
|
||
const loadingText = ref('')
|
||
const sidebarCollapsed = ref(false)
|
||
|
||
function setLoading(visible, text = '') {
|
||
loading.value = visible
|
||
loadingText.value = text
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||
}
|
||
|
||
return {
|
||
loading, loadingText, sidebarCollapsed,
|
||
setLoading, toggleSidebar
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 11: 验证 Store 可正常工作**
|
||
|
||
Run: 在浏览器控制台测试各 Store 的 actions 是否正常
|
||
Expected: 无报错,状态正确更新
|
||
|
||
---
|
||
|
||
### Task 4: 创建布局组件和路由守卫
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/layout/AppHeader.vue`
|
||
- Create: `frontend/src/components/layout/AppSidebar.vue`
|
||
- Modify: `frontend/src/router/index.js`
|
||
- Modify: `frontend/src/App.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: auth Store
|
||
- Produces: 带路由守卫的完整路由配置,布局组件
|
||
|
||
- [ ] **Step 1: 创建 AppHeader.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<el-menu mode="horizontal" :ellipsis="false" class="app-header">
|
||
<div class="app-title">仓库物料管理系统</div>
|
||
<div class="user-info">
|
||
<span class="username">{{ authStore.user?.username }}</span>
|
||
<el-tag v-if="authStore.isAdmin" type="danger" size="small">管理员</el-tag>
|
||
<el-tag v-else type="info" size="small">普通用户</el-tag>
|
||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||
</div>
|
||
</el-menu>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { useAuthStore } from '../../stores/auth'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
const authStore = useAuthStore()
|
||
const router = useRouter()
|
||
|
||
function handleLogout() {
|
||
authStore.logout()
|
||
router.push('/login')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.app-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.app-title {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
padding: 0 20px;
|
||
}
|
||
.user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 0 20px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 AppSidebar.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<el-menu
|
||
:default-active="activeMenu"
|
||
class="app-sidebar"
|
||
@select="handleSelect"
|
||
>
|
||
<el-menu-item
|
||
v-for="item in visibleMenuItems"
|
||
:key="item.path"
|
||
:index="item.path"
|
||
>
|
||
<span>{{ item.label }}</span>
|
||
</el-menu-item>
|
||
</el-menu>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
import { useAuthStore } from '../../stores/auth'
|
||
|
||
const props = defineProps({
|
||
activeMenu: String
|
||
})
|
||
|
||
const emit = defineEmits(['menu-select'])
|
||
|
||
const authStore = useAuthStore()
|
||
|
||
const menuItems = [
|
||
{ path: '/materials', label: '物料管理', adminOnly: true },
|
||
{ path: '/inbound', label: '入库管理', adminOnly: true },
|
||
{ path: '/outbound', label: '出库管理', adminOnly: true },
|
||
{ path: '/stock', label: '库存查询', adminOnly: false },
|
||
{ path: '/application', label: '物料申请', adminOnly: false },
|
||
{ path: '/approval', label: '申请审批', adminOnly: true },
|
||
{ path: '/logs', label: '日志记录', adminOnly: true },
|
||
{ path: '/users', label: '用户管理', adminOnly: true }
|
||
]
|
||
|
||
const visibleMenuItems = computed(() => {
|
||
return menuItems.filter(item => {
|
||
if (item.adminOnly) return authStore.isAdmin
|
||
return true
|
||
})
|
||
})
|
||
|
||
function handleSelect(path) {
|
||
emit('menu-select', path)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.app-sidebar {
|
||
height: 100%;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: 更新 router/index.js 添加路由和守卫**
|
||
|
||
```js
|
||
import { createRouter, createWebHistory } from 'vue-router'
|
||
import { useAuthStore } from '../stores/auth'
|
||
|
||
const routes = [
|
||
{
|
||
path: '/login',
|
||
name: 'Login',
|
||
component: () => import('../views/Login.vue'),
|
||
meta: { guestOnly: true }
|
||
},
|
||
{
|
||
path: '/',
|
||
redirect: '/stock'
|
||
},
|
||
{
|
||
path: '/materials',
|
||
name: 'Materials',
|
||
component: () => import('../views/Materials.vue'),
|
||
meta: { requiresAuth: true, adminOnly: true }
|
||
},
|
||
{
|
||
path: '/inbound',
|
||
name: 'Inbound',
|
||
component: () => import('../views/Inbound.vue'),
|
||
meta: { requiresAuth: true, adminOnly: true }
|
||
},
|
||
{
|
||
path: '/outbound',
|
||
name: 'Outbound',
|
||
component: () => import('../views/Outbound.vue'),
|
||
meta: { requiresAuth: true, adminOnly: true }
|
||
},
|
||
{
|
||
path: '/stock',
|
||
name: 'Stock',
|
||
component: () => import('../views/Stock.vue'),
|
||
meta: { requiresAuth: true }
|
||
},
|
||
{
|
||
path: '/application',
|
||
name: 'Application',
|
||
component: () => import('../views/Application.vue'),
|
||
meta: { requiresAuth: true }
|
||
},
|
||
{
|
||
path: '/approval',
|
||
name: 'Approval',
|
||
component: () => import('../views/Approval.vue'),
|
||
meta: { requiresAuth: true, adminOnly: true }
|
||
},
|
||
{
|
||
path: '/logs',
|
||
name: 'Logs',
|
||
component: () => import('../views/Logs.vue'),
|
||
meta: { requiresAuth: true, adminOnly: true }
|
||
},
|
||
{
|
||
path: '/users',
|
||
name: 'Users',
|
||
component: () => import('../views/Users.vue'),
|
||
meta: { requiresAuth: true, adminOnly: true }
|
||
}
|
||
]
|
||
|
||
const router = createRouter({
|
||
history: createWebHistory(),
|
||
routes
|
||
})
|
||
|
||
router.beforeEach((to, from, next) => {
|
||
const authStore = useAuthStore()
|
||
|
||
if (to.meta.guestOnly && authStore.isLoggedIn) {
|
||
return next('/stock')
|
||
}
|
||
|
||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||
return next('/login')
|
||
}
|
||
|
||
if (to.meta.adminOnly && !authStore.isAdmin) {
|
||
return next('/stock')
|
||
}
|
||
|
||
next()
|
||
})
|
||
|
||
export default router
|
||
```
|
||
|
||
- [ ] **Step 4: 更新 App.vue 添加布局**
|
||
|
||
```vue
|
||
<template>
|
||
<div id="app">
|
||
<template v-if="authStore.isLoggedIn">
|
||
<AppHeader />
|
||
<div class="app-container">
|
||
<AppSidebar :active-menu="currentRoute" @menu-select="handleMenuSelect" />
|
||
<main class="app-main">
|
||
<router-view />
|
||
</main>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<router-view />
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useAuthStore } from './stores/auth'
|
||
import AppHeader from './components/layout/AppHeader.vue'
|
||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
|
||
const currentRoute = computed(() => route.path)
|
||
|
||
function handleMenuSelect(path) {
|
||
router.push(path)
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
#app {
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.app-container {
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
.app-sidebar {
|
||
width: 200px;
|
||
flex-shrink: 0;
|
||
}
|
||
.app-main {
|
||
flex: 1;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 5: 创建 Login.vue 基础版本**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="login-page">
|
||
<div class="login-container">
|
||
<h2>仓库物料管理系统</h2>
|
||
<el-form :model="form" @submit.prevent="handleLogin">
|
||
<el-form-item label="用户名">
|
||
<el-input v-model="form.username" />
|
||
</el-form-item>
|
||
<el-form-item label="密码">
|
||
<el-input v-model="form.password" type="password" />
|
||
</el-form-item>
|
||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">
|
||
登录
|
||
</el-button>
|
||
</el-form>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
|
||
const form = ref({ username: '', password: '' })
|
||
const loading = ref(false)
|
||
|
||
async function handleLogin() {
|
||
if (!form.value.username || !form.value.password) {
|
||
ElMessage.warning('请输入用户名和密码')
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
await authStore.login(form.value.username, form.value.password)
|
||
router.push('/stock')
|
||
} catch (e) {
|
||
// 错误已在拦截器处理
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.login-page {
|
||
height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f5f5f5;
|
||
}
|
||
.login-container {
|
||
width: 400px;
|
||
padding: 40px;
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 6: 验证 Day 1 完成的功能**
|
||
|
||
Run: `cd frontend && npm run dev`
|
||
Expected:
|
||
- 访问 localhost:8081 自动跳转 /login
|
||
- 登录页显示正常
|
||
- 登录成功后跳转 /stock
|
||
- 顶部导航和侧边栏显示正常
|
||
- 侧边栏根据 admin 角色显示菜单项
|
||
|
||
---
|
||
|
||
## Day 2: 基础组件 + 登录/物料/库存页面
|
||
|
||
### Task 5: 创建基础业务组件
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/business/StatusBadge.vue`
|
||
- Create: `frontend/src/components/business/RoleBadge.vue`
|
||
- Create: `frontend/src/components/business/DeleteConfirm.vue`
|
||
- Create: `frontend/src/components/business/AlertTip.vue`
|
||
- Create: `frontend/src/components/business/LoadingOverlay.vue`
|
||
- Create: `frontend/src/components/business/BasePagination.vue`
|
||
- Create: `frontend/src/components/business/BaseTable.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: 无
|
||
- Produces: 可复用的基础业务组件
|
||
|
||
- [ ] **Step 1: 创建 StatusBadge.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<el-tag :type="tagType" size="small">{{ label }}</el-tag>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
|
||
const props = defineProps({
|
||
status: {
|
||
type: String,
|
||
required: true
|
||
}
|
||
})
|
||
|
||
const statusMap = {
|
||
pending: { type: 'warning', label: '待处理' },
|
||
processing: { type: '', label: '处理中' },
|
||
completed: { type: 'success', label: '已完成' },
|
||
rejected: { type: 'danger', label: '已拒绝' },
|
||
approved: { type: 'success', label: '已通过' },
|
||
cancelled: { type: 'info', label: '已取消' }
|
||
}
|
||
|
||
const tagType = computed(() => statusMap[props.status]?.type || 'info')
|
||
const label = computed(() => statusMap[props.status]?.label || props.status)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 RoleBadge.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<el-tag :type="tagType" size="small">{{ label }}</el-tag>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
|
||
const props = defineProps({
|
||
role: {
|
||
type: String,
|
||
required: true
|
||
}
|
||
})
|
||
|
||
const tagType = computed(() => props.role === 'admin' ? 'danger' : 'info')
|
||
const label = computed(() => props.role === 'admin' ? '管理员' : '普通用户')
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 DeleteConfirm.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<el-dialog v-model="visible" title="确认删除" width="400px">
|
||
<p>{{ content }}</p>
|
||
<template #footer>
|
||
<el-button @click="visible = false">取消</el-button>
|
||
<el-button type="danger" @click="handleConfirm" :loading="loading">确认删除</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch } from 'vue'
|
||
|
||
const props = defineProps({
|
||
modelValue: Boolean,
|
||
content: {
|
||
type: String,
|
||
default: '确定要删除此数据吗?此操作不可恢复。'
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||
|
||
const visible = ref(props.modelValue)
|
||
const loading = ref(false)
|
||
|
||
watch(() => props.modelValue, (val) => {
|
||
visible.value = val
|
||
})
|
||
|
||
watch(visible, (val) => {
|
||
emit('update:modelValue', val)
|
||
})
|
||
|
||
async function handleConfirm() {
|
||
loading.value = true
|
||
try {
|
||
emit('confirm')
|
||
visible.value = false
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 AlertTip.vue**
|
||
|
||
```vue
|
||
<script setup>
|
||
import { watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const props = defineProps({
|
||
message: String,
|
||
type: {
|
||
type: String,
|
||
default: 'success'
|
||
},
|
||
duration: {
|
||
type: Number,
|
||
default: 3000
|
||
}
|
||
})
|
||
|
||
watch(() => props.message, (newMsg) => {
|
||
if (newMsg) {
|
||
ElMessage({
|
||
message: newMsg,
|
||
type: props.type,
|
||
duration: props.duration
|
||
})
|
||
}
|
||
}, { immediate: true })
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 5: 创建 LoadingOverlay.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div v-if="visible" class="loading-overlay">
|
||
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
|
||
<p v-if="text">{{ text }}</p>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { Loading } from '@element-plus/icons-vue'
|
||
|
||
defineProps({
|
||
visible: Boolean,
|
||
text: String
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.loading-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
color: white;
|
||
}
|
||
.loading-overlay p {
|
||
margin-top: 10px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 6: 创建 BasePagination.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="pagination-container">
|
||
<span class="page-info">{{ pageInfo }}</span>
|
||
<el-pagination
|
||
v-model:current-page="currentPage"
|
||
:page-size="pageSize"
|
||
:total="total"
|
||
:pager-count="5"
|
||
layout="prev, pager, next"
|
||
@current-change="handleChange"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
|
||
const props = defineProps({
|
||
currentPage: Number,
|
||
pageSize: Number,
|
||
total: Number
|
||
})
|
||
|
||
const emit = defineEmits(['update:currentPage', 'change'])
|
||
|
||
const currentPage = computed({
|
||
get: () => props.currentPage,
|
||
set: (val) => emit('update:currentPage', val)
|
||
})
|
||
|
||
const pageInfo = computed(() => {
|
||
const start = (props.currentPage - 1) * props.pageSize + 1
|
||
const end = Math.min(props.currentPage * props.pageSize, props.total)
|
||
return `显示 ${start}-${end} 条,共 ${props.total} 条`
|
||
})
|
||
|
||
function handleChange(page) {
|
||
emit('change', page)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pagination-container {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 15px;
|
||
}
|
||
.page-info {
|
||
color: #666;
|
||
font-size: 14px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 7: 创建 BaseTable.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="base-table">
|
||
<el-table
|
||
:data="data"
|
||
v-loading="loading"
|
||
stripe
|
||
border
|
||
@selection-change="handleSelectionChange"
|
||
>
|
||
<slot />
|
||
<template #empty>
|
||
<span>暂无数据</span>
|
||
</template>
|
||
</el-table>
|
||
<BasePagination
|
||
v-if="showPagination"
|
||
:current-page="currentPage"
|
||
:page-size="pageSize"
|
||
:total="total"
|
||
@change="handlePageChange"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import BasePagination from './BasePagination.vue'
|
||
|
||
defineProps({
|
||
data: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
loading: Boolean,
|
||
showPagination: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
currentPage: Number,
|
||
pageSize: Number,
|
||
total: Number
|
||
})
|
||
|
||
const emit = defineEmits(['update:currentPage', 'page-change', 'selection-change'])
|
||
|
||
function handlePageChange(page) {
|
||
emit('update:currentPage', page)
|
||
emit('page-change', page)
|
||
}
|
||
|
||
function handleSelectionChange(selection) {
|
||
emit('selection-change', selection)
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 8: 验证基础组件可用**
|
||
|
||
Run: 在 Vue 组件中引入测试
|
||
Expected: 组件正常渲染,无报错
|
||
|
||
---
|
||
|
||
### Task 6: 创建 Materials 页面
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/views/Materials.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: materials Store, materials API
|
||
- Produces: 物料管理完整页面
|
||
|
||
- [ ] **Step 1: 创建 Materials.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="materials-page">
|
||
<div class="page-header">
|
||
<h2>物料管理</h2>
|
||
<el-button type="primary" @click="showAddModal">新增物料</el-button>
|
||
</div>
|
||
|
||
<div class="search-bar">
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索物料编码或名称..."
|
||
@keyup.enter="handleSearch"
|
||
style="width: 300px"
|
||
/>
|
||
<el-button @click="handleSearch">搜索</el-button>
|
||
</div>
|
||
|
||
<BaseTable
|
||
:data="materialsStore.materials"
|
||
:loading="materialsStore.loading"
|
||
:current-page="materialsStore.pagination.currentPage"
|
||
:page-size="materialsStore.pagination.pageSize"
|
||
:total="materialsStore.pagination.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="material_code" label="物料编码" />
|
||
<el-table-column prop="name" label="名称" />
|
||
<el-table-column prop="material_image" label="物料图片" width="80">
|
||
<template #default="{ row }">
|
||
<el-image
|
||
v-if="row.material_image"
|
||
:src="row.material_image"
|
||
style="width: 40px; height: 40px; object-fit: contain"
|
||
:preview-src-list="[row.material_image]"
|
||
fit="contain"
|
||
/>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="spec" label="规格" />
|
||
<el-table-column prop="category" label="分类">
|
||
<template #default="{ row }">
|
||
{{ materialsStore.getCategoryDisplay(row.category) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="unit" label="单位" width="60" />
|
||
<el-table-column prop="warning_stock" label="预警库存" width="100" />
|
||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="150" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
|
||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
|
||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||
<el-form :model="form" label-width="100px">
|
||
<el-form-item label="物料编码">
|
||
<el-input v-model="form.material_code" />
|
||
</el-form-item>
|
||
<el-form-item label="名称">
|
||
<el-input v-model="form.name" />
|
||
</el-form-item>
|
||
<el-form-item label="规格">
|
||
<el-input v-model="form.spec" />
|
||
</el-form-item>
|
||
<el-form-item label="分类">
|
||
<el-select v-model="form.category" style="width: 100%">
|
||
<el-option label="标准件" value="A01" />
|
||
<el-option label="成品件" value="A02" />
|
||
<el-option label="简易加工件" value="A03" />
|
||
<el-option label="精密加工件" value="A04" />
|
||
<el-option label="钣金件" value="A05" />
|
||
<el-option label="塑胶件" value="A06" />
|
||
<el-option label="其他" value="A07" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="单位">
|
||
<el-input v-model="form.unit" />
|
||
</el-form-item>
|
||
<el-form-item label="预警库存">
|
||
<el-input-number v-model="form.warning_stock" :min="0" />
|
||
</el-form-item>
|
||
<el-form-item label="图片">
|
||
<el-input v-model="form.material_image" placeholder="图片URL" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<DeleteConfirm
|
||
v-model="deleteDialogVisible"
|
||
@confirm="confirmDelete"
|
||
/>
|
||
|
||
<AlertTip :message="alertMessage" :type="alertType" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { useMaterialsStore } from '../stores/materials'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
|
||
import AlertTip from '../components/business/AlertTip.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const materialsStore = useMaterialsStore()
|
||
|
||
const searchKeyword = ref('')
|
||
const dialogVisible = ref(false)
|
||
const deleteDialogVisible = ref(false)
|
||
const dialogTitle = ref('新增物料')
|
||
const saveLoading = ref(false)
|
||
const editingId = ref(null)
|
||
const deleteId = ref(null)
|
||
const alertMessage = ref('')
|
||
const alertType = ref('success')
|
||
|
||
const form = reactive({
|
||
material_code: '',
|
||
name: '',
|
||
spec: '',
|
||
category: '',
|
||
unit: 'pcs',
|
||
warning_stock: 0,
|
||
material_image: ''
|
||
})
|
||
|
||
onMounted(() => {
|
||
materialsStore.fetchMaterials()
|
||
})
|
||
|
||
function handleSearch() {
|
||
materialsStore.fetchMaterials(1, searchKeyword.value)
|
||
}
|
||
|
||
function handlePageChange(page) {
|
||
materialsStore.fetchMaterials(page, searchKeyword.value)
|
||
}
|
||
|
||
function showAddModal() {
|
||
editingId.value = null
|
||
dialogTitle.value = '新增物料'
|
||
Object.assign(form, {
|
||
material_code: '',
|
||
name: '',
|
||
spec: '',
|
||
category: '',
|
||
unit: 'pcs',
|
||
warning_stock: 0,
|
||
material_image: ''
|
||
})
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function showEditModal(row) {
|
||
editingId.value = row.id
|
||
dialogTitle.value = '编辑物料'
|
||
Object.assign(form, {
|
||
material_code: row.material_code,
|
||
name: row.name,
|
||
spec: row.spec || '',
|
||
category: row.category || '',
|
||
unit: row.unit,
|
||
warning_stock: row.warning_stock,
|
||
material_image: row.material_image || ''
|
||
})
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleSave() {
|
||
saveLoading.value = true
|
||
try {
|
||
if (editingId.value) {
|
||
await materialsStore.updateMaterial(editingId.value, { ...form })
|
||
ElMessage.success('物料更新成功')
|
||
} else {
|
||
await materialsStore.createMaterial({ ...form })
|
||
ElMessage.success('物料创建成功')
|
||
}
|
||
dialogVisible.value = false
|
||
} catch (e) {
|
||
// 错误已由 API 层处理
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleDelete(id) {
|
||
deleteId.value = id
|
||
deleteDialogVisible.value = true
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
try {
|
||
await materialsStore.deleteMaterial(deleteId.value)
|
||
ElMessage.success('物料删除成功')
|
||
} catch (e) {
|
||
// 错误已由 API 层处理
|
||
}
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.materials-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.search-bar {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 15px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 验证 Materials 页面**
|
||
|
||
Run: 访问 /materials
|
||
Expected: 物料列表显示正常,分页正常,搜索正常,新增/编辑/删除功能正常
|
||
|
||
---
|
||
|
||
### Task 7: 创建 Stock 页面
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/views/Stock.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: stock Store
|
||
- Produces: 库存查询、预警、日志页面
|
||
|
||
- [ ] **Step 1: 创建 Stock.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="stock-page">
|
||
<el-tabs v-model="activeTab">
|
||
<el-tab-pane label="库存查询" name="list">
|
||
<BaseTable
|
||
:data="stockStore.stockList"
|
||
:loading="stockStore.loading"
|
||
:current-page="stockStore.stockPage.currentPage"
|
||
:page-size="stockStore.stockPage.pageSize"
|
||
:total="stockStore.stockPage.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="material_id" label="物料ID" />
|
||
<el-table-column prop="material_name" label="物料名称" />
|
||
<el-table-column prop="material_code" label="物料编码" />
|
||
<el-table-column prop="quantity" label="库存数量" />
|
||
<el-table-column prop="warning_stock" label="预警值" />
|
||
<el-table-column label="状态">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.quantity <= row.warning_stock" type="danger">库存不足</el-tag>
|
||
<el-tag v-else type="success">正常</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="库存预警" name="warning">
|
||
<BaseTable
|
||
:data="stockStore.warningList"
|
||
:loading="stockStore.loading"
|
||
:current-page="stockStore.warningPage.currentPage"
|
||
:page-size="stockStore.warningPage.pageSize"
|
||
:total="stockStore.warningPage.total"
|
||
@page-change="handleWarningPageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="material_id" label="物料ID" />
|
||
<el-table-column prop="material_name" label="物料名称" />
|
||
<el-table-column prop="material_code" label="物料编码" />
|
||
<el-table-column prop="quantity" label="当前库存">
|
||
<template #default="{ row }">
|
||
<span style="color: red">{{ row.quantity }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="warning_stock" label="预警值" />
|
||
<el-table-column label="缺货量">
|
||
<template #default="{ row }">
|
||
{{ Math.max(0, row.warning_stock - row.quantity) }}
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="库存日志" name="logs">
|
||
<div class="logs-filter">
|
||
<el-input
|
||
v-model="logMaterialId"
|
||
placeholder="物料ID"
|
||
style="width: 150px"
|
||
/>
|
||
<el-button @click="loadLogs">查询</el-button>
|
||
</div>
|
||
<BaseTable
|
||
:data="stockStore.logs"
|
||
:loading="stockStore.loading"
|
||
:current-page="stockStore.logsPage.currentPage"
|
||
:page-size="stockStore.logsPage.pageSize"
|
||
:total="stockStore.logsPage.total"
|
||
@page-change="handleLogsPageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="material_id" label="物料ID" />
|
||
<el-table-column prop="change_type" label="变动类型">
|
||
<template #default="{ row }">
|
||
<StatusBadge :status="row.change_type" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="change_quantity" label="变动数量" />
|
||
<el-table-column prop="before_quantity" label="变动前" />
|
||
<el-table-column prop="after_quantity" label="变动后" />
|
||
<el-table-column prop="reference_type" label="关联类型" />
|
||
<el-table-column prop="created_at" label="时间">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted } from 'vue'
|
||
import { useStockStore } from '../stores/stock'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import StatusBadge from '../components/business/StatusBadge.vue'
|
||
|
||
const stockStore = useStockStore()
|
||
|
||
const activeTab = ref('list')
|
||
const logMaterialId = ref('')
|
||
|
||
onMounted(() => {
|
||
stockStore.fetchStock()
|
||
stockStore.fetchStockWarning()
|
||
})
|
||
|
||
function handlePageChange(page) {
|
||
stockStore.fetchStock(page)
|
||
}
|
||
|
||
function handleWarningPageChange(page) {
|
||
stockStore.fetchStockWarning(page)
|
||
}
|
||
|
||
function loadLogs() {
|
||
if (logMaterialId.value) {
|
||
stockStore.fetchStockLogs(logMaterialId.value)
|
||
}
|
||
}
|
||
|
||
function handleLogsPageChange(page) {
|
||
if (logMaterialId.value) {
|
||
stockStore.fetchStockLogs(logMaterialId.value, page)
|
||
}
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.stock-page {
|
||
padding: 20px;
|
||
}
|
||
.logs-filter {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 15px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 验证 Stock 页面**
|
||
|
||
Run: 访问 /stock
|
||
Expected: 三个 Tab 页切换正常,库存列表、预警、日志显示正常
|
||
|
||
---
|
||
|
||
## Day 3: 出入库/申请审批/日志用户/权限
|
||
|
||
### Task 8: 创建 Inbound/Outbound 页面
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/views/Inbound.vue`
|
||
- Create: `frontend/src/views/Outbound.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: inbound Store, outbound Store, materials Store
|
||
- Produces: 入库/出库管理完整页面
|
||
|
||
- [ ] **Step 1: 创建 Inbound.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="inbound-page">
|
||
<div class="page-header">
|
||
<h2>入库管理</h2>
|
||
<el-button type="primary" @click="showAddModal">新增入库单</el-button>
|
||
</div>
|
||
|
||
<BaseTable
|
||
:data="inboundStore.inboundList"
|
||
:loading="inboundStore.loading"
|
||
:current-page="inboundStore.page.currentPage"
|
||
:page-size="inboundStore.page.pageSize"
|
||
:total="inboundStore.page.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="inbound_no" label="入库单号" />
|
||
<el-table-column prop="status" label="状态">
|
||
<template #default="{ row }">
|
||
<StatusBadge :status="row.status" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="operator" label="操作人" />
|
||
<el-table-column prop="remark" label="备注" />
|
||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="showDetail(row)">详情</el-button>
|
||
<el-button size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
|
||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
|
||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px">
|
||
<el-form :model="form" label-width="100px">
|
||
<el-form-item label="备注">
|
||
<el-input v-model="form.remark" type="textarea" rows="3" />
|
||
</el-form-item>
|
||
<el-form-item label="入库物料">
|
||
<div v-for="(item, index) in form.items" :key="index" class="item-row">
|
||
<el-select v-model="item.material_id" placeholder="选择物料" style="width: 200px">
|
||
<el-option
|
||
v-for="m in materialsStore.materials"
|
||
:key="m.id"
|
||
:label="`${m.material_code} - ${m.name}`"
|
||
:value="m.id"
|
||
/>
|
||
</el-select>
|
||
<el-input-number v-model="item.quantity" :min="1" />
|
||
<el-button @click="removeItem(index)" :disabled="form.items.length <= 1">删除</el-button>
|
||
</div>
|
||
<el-button @click="addItem">添加物料</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="detailVisible" title="入库单详情" width="600px">
|
||
<div v-if="currentRow">
|
||
<p><strong>入库单号:</strong>{{ currentRow.inbound_no }}</p>
|
||
<p><strong>状态:</strong><StatusBadge :status="currentRow.status" /></p>
|
||
<p><strong>操作人:</strong>{{ currentRow.operator }}</p>
|
||
<p><strong>备注:</strong>{{ currentRow.remark || '-' }}</p>
|
||
<p><strong>创建时间:</strong>{{ formatDate(currentRow.created_at) }}</p>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { useInboundStore } from '../stores/inbound'
|
||
import { useMaterialsStore } from '../stores/materials'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import StatusBadge from '../components/business/StatusBadge.vue'
|
||
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const inboundStore = useInboundStore()
|
||
const materialsStore = useMaterialsStore()
|
||
|
||
const dialogVisible = ref(false)
|
||
const detailVisible = ref(false)
|
||
const deleteDialogVisible = ref(false)
|
||
const dialogTitle = ref('新增入库单')
|
||
const saveLoading = ref(false)
|
||
const editingId = ref(null)
|
||
const deleteId = ref(null)
|
||
const currentRow = ref(null)
|
||
|
||
const form = reactive({
|
||
remark: '',
|
||
items: [{ material_id: null, quantity: 1 }]
|
||
})
|
||
|
||
onMounted(() => {
|
||
inboundStore.fetchInboundList()
|
||
materialsStore.fetchMaterials()
|
||
})
|
||
|
||
function handlePageChange(page) {
|
||
inboundStore.fetchInboundList(page)
|
||
}
|
||
|
||
function showAddModal() {
|
||
editingId.value = null
|
||
dialogTitle.value = '新增入库单'
|
||
Object.assign(form, { remark: '', items: [{ material_id: null, quantity: 1 }] })
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function showEditModal(row) {
|
||
editingId.value = row.id
|
||
dialogTitle.value = '编辑入库单'
|
||
// 实际应从 API 获取详情
|
||
Object.assign(form, { remark: row.remark || '', items: [{ material_id: null, quantity: 1 }] })
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function showDetail(row) {
|
||
currentRow.value = row
|
||
detailVisible.value = true
|
||
}
|
||
|
||
function addItem() {
|
||
form.items.push({ material_id: null, quantity: 1 })
|
||
}
|
||
|
||
function removeItem(index) {
|
||
form.items.splice(index, 1)
|
||
}
|
||
|
||
async function handleSave() {
|
||
saveLoading.value = true
|
||
try {
|
||
const validItems = form.items.filter(item => item.material_id && item.quantity > 0)
|
||
if (validItems.length === 0) {
|
||
ElMessage.warning('请至少添加一个物料')
|
||
return
|
||
}
|
||
const data = { remark: form.remark, items: validItems }
|
||
if (editingId.value) {
|
||
await inboundStore.updateInbound(editingId.value, data)
|
||
} else {
|
||
await inboundStore.createInbound(data)
|
||
}
|
||
ElMessage.success('保存成功')
|
||
dialogVisible.value = false
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleDelete(id) {
|
||
deleteId.value = id
|
||
deleteDialogVisible.value = true
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
await inboundStore.deleteInbound(deleteId.value)
|
||
ElMessage.success('删除成功')
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.inbound-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.item-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 Outbound.vue(类似 Inbound.vue 结构)**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="outbound-page">
|
||
<div class="page-header">
|
||
<h2>出库管理</h2>
|
||
<el-button type="primary" @click="showAddModal">新增出库单</el-button>
|
||
</div>
|
||
|
||
<BaseTable
|
||
:data="outboundStore.outboundList"
|
||
:loading="outboundStore.loading"
|
||
:current-page="outboundStore.page.currentPage"
|
||
:page-size="outboundStore.page.pageSize"
|
||
:total="outboundStore.page.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="outbound_no" label="出库单号" />
|
||
<el-table-column prop="status" label="状态">
|
||
<template #default="{ row }">
|
||
<StatusBadge :status="row.status" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="operator" label="操作人" />
|
||
<el-table-column prop="remark" label="备注" />
|
||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="showDetail(row)">详情</el-button>
|
||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
|
||
<el-dialog v-model="dialogVisible" title="新增出库单" width="700px">
|
||
<el-form :model="form" label-width="100px">
|
||
<el-form-item label="备注">
|
||
<el-input v-model="form.remark" type="textarea" rows="3" />
|
||
</el-form-item>
|
||
<el-form-item label="出库物料">
|
||
<div v-for="(item, index) in form.items" :key="index" class="item-row">
|
||
<el-select v-model="item.material_id" placeholder="选择物料" style="width: 200px" @change="checkStock(item)">
|
||
<el-option
|
||
v-for="m in materialsStore.materials"
|
||
:key="m.id"
|
||
:label="`${m.material_code} - ${m.name}`"
|
||
:value="m.id"
|
||
/>
|
||
</el-select>
|
||
<el-input-number v-model="item.quantity" :min="1" />
|
||
<span v-if="item.stockWarning" style="color: red; font-size: 12px">库存不足</span>
|
||
<el-button @click="removeItem(index)" :disabled="form.items.length <= 1">删除</el-button>
|
||
</div>
|
||
<el-button @click="addItem">添加物料</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { useOutboundStore } from '../stores/outbound'
|
||
import { useMaterialsStore } from '../stores/materials'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import StatusBadge from '../components/business/StatusBadge.vue'
|
||
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const outboundStore = useOutboundStore()
|
||
const materialsStore = useMaterialsStore()
|
||
|
||
const dialogVisible = ref(false)
|
||
const deleteDialogVisible = ref(false)
|
||
const saveLoading = ref(false)
|
||
const deleteId = ref(null)
|
||
|
||
const form = reactive({
|
||
remark: '',
|
||
items: [{ material_id: null, quantity: 1, stockWarning: false }]
|
||
})
|
||
|
||
onMounted(() => {
|
||
outboundStore.fetchOutboundList()
|
||
materialsStore.fetchMaterials()
|
||
})
|
||
|
||
function handlePageChange(page) {
|
||
outboundStore.fetchOutboundList(page)
|
||
}
|
||
|
||
function showAddModal() {
|
||
Object.assign(form, { remark: '', items: [{ material_id: null, quantity: 1, stockWarning: false }] })
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function addItem() {
|
||
form.items.push({ material_id: null, quantity: 1, stockWarning: false })
|
||
}
|
||
|
||
function removeItem(index) {
|
||
form.items.splice(index, 1)
|
||
}
|
||
|
||
function checkStock(item) {
|
||
const material = materialsStore.materials.find(m => m.id === item.material_id)
|
||
if (material && item.quantity > material.quantity) {
|
||
item.stockWarning = true
|
||
} else {
|
||
item.stockWarning = false
|
||
}
|
||
}
|
||
|
||
async function handleSave() {
|
||
saveLoading.value = true
|
||
try {
|
||
const validItems = form.items.filter(item => item.material_id && item.quantity > 0)
|
||
if (validItems.length === 0) {
|
||
ElMessage.warning('请至少添加一个物料')
|
||
return
|
||
}
|
||
await outboundStore.createOutbound({ remark: form.remark, items: validItems })
|
||
ElMessage.success('保存成功')
|
||
dialogVisible.value = false
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleDelete(id) {
|
||
deleteId.value = id
|
||
deleteDialogVisible.value = true
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
await outboundStore.deleteOutbound(deleteId.value)
|
||
ElMessage.success('删除成功')
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.outbound-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.item-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
align-items: center;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 Inbound/Outbound 页面**
|
||
|
||
Run: 访问 /inbound 和 /outbound
|
||
Expected: 入库/出库单列表显示正常,新增功能正常
|
||
|
||
---
|
||
|
||
### Task 9: 创建 Application/Approval/Logs/Users 页面
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/views/Application.vue`
|
||
- Create: `frontend/src/views/Approval.vue`
|
||
- Create: `frontend/src/views/Logs.vue`
|
||
- Create: `frontend/src/views/Users.vue`
|
||
|
||
- [ ] **Step 1: 创建 Application.vue(物料申请表单 + 我的申请列表)**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="application-page">
|
||
<el-tabs v-model="activeTab">
|
||
<el-tab-pane label="发起申请" name="apply">
|
||
<el-form :model="applyForm" label-width="100px">
|
||
<el-form-item label="申请用途">
|
||
<el-input v-model="applyForm.purpose" type="textarea" rows="3" />
|
||
</el-form-item>
|
||
<el-form-item label="申请物料">
|
||
<div v-for="(item, index) in applyForm.items" :key="index" class="item-row">
|
||
<el-select v-model="item.material_id" placeholder="选择物料" style="width: 250px">
|
||
<el-option
|
||
v-for="m in materialsStore.materials"
|
||
:key="m.id"
|
||
:label="`${m.material_code} - ${m.name}`"
|
||
:value="m.id"
|
||
/>
|
||
</el-select>
|
||
<el-input-number v-model="item.quantity" :min="1" />
|
||
<el-button @click="removeItem(index)" :disabled="applyForm.items.length <= 1">删除</el-button>
|
||
</div>
|
||
<el-button @click="addItem">添加物料</el-button>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">提交申请</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="我的申请" name="my">
|
||
<BaseTable
|
||
:data="applicationStore.myApplications"
|
||
:loading="applicationStore.loading"
|
||
:current-page="applicationStore.myPage.currentPage"
|
||
:page-size="applicationStore.myPage.pageSize"
|
||
:total="applicationStore.myPage.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="application_no" label="申请单号" />
|
||
<el-table-column prop="status" label="状态">
|
||
<template #default="{ row }">
|
||
<StatusBadge :status="row.status" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="purpose" label="用途" />
|
||
<el-table-column prop="created_at" label="申请时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="row.status === 'pending'"
|
||
size="small"
|
||
type="danger"
|
||
@click="handleCancel(row.id)"
|
||
>
|
||
取消
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { useApplicationStore } from '../stores/application'
|
||
import { useMaterialsStore } from '../stores/materials'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import StatusBadge from '../components/business/StatusBadge.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const applicationStore = useApplicationStore()
|
||
const materialsStore = useMaterialsStore()
|
||
|
||
const activeTab = ref('apply')
|
||
const submitLoading = ref(false)
|
||
|
||
const applyForm = reactive({
|
||
purpose: '',
|
||
items: [{ material_id: null, quantity: 1 }]
|
||
})
|
||
|
||
onMounted(() => {
|
||
materialsStore.fetchMaterials()
|
||
applicationStore.fetchMyApplications()
|
||
})
|
||
|
||
function handlePageChange(page) {
|
||
applicationStore.fetchMyApplications('', page)
|
||
}
|
||
|
||
function addItem() {
|
||
applyForm.items.push({ material_id: null, quantity: 1 })
|
||
}
|
||
|
||
function removeItem(index) {
|
||
applyForm.items.splice(index, 1)
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!applyForm.purpose.trim()) {
|
||
ElMessage.warning('请填写申请用途')
|
||
return
|
||
}
|
||
const validItems = applyForm.items.filter(item => item.material_id && item.quantity > 0)
|
||
if (validItems.length === 0) {
|
||
ElMessage.warning('请至少添加一个物料')
|
||
return
|
||
}
|
||
submitLoading.value = true
|
||
try {
|
||
await applicationStore.createApplication({
|
||
purpose: applyForm.purpose,
|
||
items: validItems
|
||
})
|
||
ElMessage.success('申请提交成功')
|
||
applyForm.purpose = ''
|
||
applyForm.items = [{ material_id: null, quantity: 1 }]
|
||
activeTab.value = 'my'
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleCancel(id) {
|
||
await applicationStore.cancelApplication(id)
|
||
ElMessage.success('申请已取消')
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.application-page {
|
||
padding: 20px;
|
||
}
|
||
.item-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 Approval.vue(申请审批列表)**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="approval-page">
|
||
<div class="page-header">
|
||
<h2>申请审批</h2>
|
||
<el-select v-model="statusFilter" placeholder="状态筛选" style="width: 150px" @change="handleFilter">
|
||
<el-option label="全部" value="" />
|
||
<el-option label="待处理" value="pending" />
|
||
<el-option label="已通过" value="approved" />
|
||
<el-option label="已拒绝" value="rejected" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<BaseTable
|
||
:data="approvalStore.applications"
|
||
:loading="approvalStore.loading"
|
||
:current-page="approvalStore.page.currentPage"
|
||
:page-size="approvalStore.page.pageSize"
|
||
:total="approvalStore.page.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="application_no" label="申请单号" />
|
||
<el-table-column prop="applicant" label="申请人" />
|
||
<el-table-column prop="status" label="状态">
|
||
<template #default="{ row }">
|
||
<StatusBadge :status="row.status" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="purpose" label="用途" />
|
||
<el-table-column prop="created_at" label="申请时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="200" fixed="right">
|
||
<template #default="{ row }">
|
||
<template v-if="row.status === 'pending'">
|
||
<el-button size="small" type="success" @click="showApproveModal(row)">通过</el-button>
|
||
<el-button size="small" type="danger" @click="showRejectModal(row)">拒绝</el-button>
|
||
</template>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
|
||
<el-dialog v-model="approveDialogVisible" title="审批通过" width="400px">
|
||
<el-form :model="approveForm" label-width="100px">
|
||
<el-form-item label="自动出库">
|
||
<el-switch v-model="approveForm.autoOutbound" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="approveDialogVisible = false">取消</el-button>
|
||
<el-button type="success" @click="handleApprove">确认通过</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="rejectDialogVisible" title="拒绝申请" width="400px">
|
||
<el-form :model="rejectForm" label-width="100px">
|
||
<el-form-item label="拒绝原因">
|
||
<el-input v-model="rejectForm.reason" type="textarea" rows="3" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||
<el-button type="danger" @click="handleReject">确认拒绝</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { useApprovalStore } from '../stores/approval'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import StatusBadge from '../components/business/StatusBadge.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const approvalStore = useApprovalStore()
|
||
|
||
const statusFilter = ref('')
|
||
const approveDialogVisible = ref(false)
|
||
const rejectDialogVisible = ref(false)
|
||
const currentApplication = ref(null)
|
||
|
||
const approveForm = reactive({ autoOutbound: false })
|
||
const rejectForm = reactive({ reason: '' })
|
||
|
||
onMounted(() => {
|
||
approvalStore.fetchPendingApplications()
|
||
})
|
||
|
||
function handleFilter() {
|
||
approvalStore.fetchPendingApplications(statusFilter.value)
|
||
}
|
||
|
||
function handlePageChange(page) {
|
||
approvalStore.fetchPendingApplications(statusFilter.value, page)
|
||
}
|
||
|
||
function showApproveModal(row) {
|
||
currentApplication.value = row
|
||
approveForm.autoOutbound = false
|
||
approveDialogVisible.value = true
|
||
}
|
||
|
||
async function handleApprove() {
|
||
await approvalStore.approveApplication(currentApplication.value.id, {
|
||
auto_outbound: approveForm.autoOutbound
|
||
})
|
||
ElMessage.success('审批通过')
|
||
approveDialogVisible.value = false
|
||
}
|
||
|
||
function showRejectModal(row) {
|
||
currentApplication.value = row
|
||
rejectForm.reason = ''
|
||
rejectDialogVisible.value = true
|
||
}
|
||
|
||
async function handleReject() {
|
||
if (!rejectForm.reason.trim()) {
|
||
ElMessage.warning('请填写拒绝原因')
|
||
return
|
||
}
|
||
await approvalStore.rejectApplication(currentApplication.value.id, {
|
||
reason: rejectForm.reason
|
||
})
|
||
ElMessage.success('已拒绝')
|
||
rejectDialogVisible.value = false
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.approval-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 Logs.vue(日志记录)**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="logs-page">
|
||
<div class="page-header">
|
||
<h2>日志记录</h2>
|
||
</div>
|
||
|
||
<div class="filter-bar">
|
||
<el-select v-model="filters.operationType" placeholder="操作类型" style="width: 150px">
|
||
<el-option label="全部" value="" />
|
||
<el-option label="入库" value="inbound" />
|
||
<el-option label="出库" value="outbound" />
|
||
<el-option label="盘点" value="stock_check" />
|
||
</el-select>
|
||
<el-date-picker
|
||
v-model="filters.dateRange"
|
||
type="daterange"
|
||
range-separator="至"
|
||
start-placeholder="开始日期"
|
||
end-placeholder="结束日期"
|
||
style="margin-left: 10px"
|
||
/>
|
||
<el-button @click="handleSearch" style="margin-left: 10px">查询</el-button>
|
||
<el-button @click="handleReset">重置</el-button>
|
||
</div>
|
||
|
||
<BaseTable
|
||
:data="logsStore.logs"
|
||
:loading="logsStore.loading"
|
||
:current-page="logsStore.page.currentPage"
|
||
:page-size="logsStore.page.pageSize"
|
||
:total="logsStore.page.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="operation_type" label="操作类型">
|
||
<template #default="{ row }">
|
||
<StatusBadge :status="row.operation_type" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="operator" label="操作人" />
|
||
<el-table-column prop="target_type" label="目标类型" />
|
||
<el-table-column prop="target_id" label="目标ID" />
|
||
<el-table-column prop="detail" label="详情" show-overflow-tooltip />
|
||
<el-table-column prop="created_at" label="时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { useLogsStore } from '../stores/logs'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import StatusBadge from '../components/business/StatusBadge.vue'
|
||
|
||
const logsStore = useLogsStore()
|
||
|
||
const filters = reactive({
|
||
operationType: '',
|
||
dateRange: null
|
||
})
|
||
|
||
onMounted(() => {
|
||
logsStore.fetchLogs()
|
||
})
|
||
|
||
function handleSearch() {
|
||
logsStore.setFilters({
|
||
operationType: filters.operationType,
|
||
startDate: filters.dateRange?.[0]?.toISOString(),
|
||
endDate: filters.dateRange?.[1]?.toISOString()
|
||
})
|
||
logsStore.fetchLogs()
|
||
}
|
||
|
||
function handleReset() {
|
||
filters.operationType = ''
|
||
filters.dateRange = null
|
||
logsStore.resetFilters()
|
||
logsStore.fetchLogs()
|
||
}
|
||
|
||
function handlePageChange(page) {
|
||
logsStore.fetchLogs(page)
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.logs-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.filter-bar {
|
||
display: flex;
|
||
margin-bottom: 15px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 Users.vue(用户管理)**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="users-page">
|
||
<div class="page-header">
|
||
<h2>用户管理</h2>
|
||
<el-button type="primary" @click="showAddModal">新增用户</el-button>
|
||
</div>
|
||
|
||
<BaseTable
|
||
:data="usersStore.users"
|
||
:loading="usersStore.loading"
|
||
:current-page="usersStore.page.currentPage"
|
||
:page-size="usersStore.page.pageSize"
|
||
:total="usersStore.page.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="username" label="用户名" />
|
||
<el-table-column prop="role" label="角色">
|
||
<template #default="{ row }">
|
||
<RoleBadge :role="row.role" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="description" label="描述" />
|
||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="150" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
|
||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
|
||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="400px">
|
||
<el-form :model="form" label-width="80px">
|
||
<el-form-item label="用户名">
|
||
<el-input v-model="form.username" />
|
||
</el-form-item>
|
||
<el-form-item label="密码" v-if="!editingId">
|
||
<el-input v-model="form.password" type="password" />
|
||
</el-form-item>
|
||
<el-form-item label="角色">
|
||
<el-select v-model="form.role" style="width: 100%">
|
||
<el-option label="管理员" value="admin" />
|
||
<el-option label="普通用户" value="user" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input v-model="form.description" type="textarea" rows="3" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { useUsersStore } from '../stores/users'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import RoleBadge from '../components/business/RoleBadge.vue'
|
||
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const usersStore = useUsersStore()
|
||
|
||
const dialogVisible = ref(false)
|
||
const deleteDialogVisible = ref(false)
|
||
const dialogTitle = ref('新增用户')
|
||
const saveLoading = ref(false)
|
||
const editingId = ref(null)
|
||
const deleteId = ref(null)
|
||
|
||
const form = reactive({
|
||
username: '',
|
||
password: '',
|
||
role: 'user',
|
||
description: ''
|
||
})
|
||
|
||
onMounted(() => {
|
||
usersStore.fetchUsers()
|
||
})
|
||
|
||
function handlePageChange(page) {
|
||
usersStore.fetchUsers(page)
|
||
}
|
||
|
||
function showAddModal() {
|
||
editingId.value = null
|
||
dialogTitle.value = '新增用户'
|
||
Object.assign(form, { username: '', password: '', role: 'user', description: '' })
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function showEditModal(row) {
|
||
editingId.value = row.id
|
||
dialogTitle.value = '编辑用户'
|
||
Object.assign(form, {
|
||
username: row.username,
|
||
password: '',
|
||
role: row.role,
|
||
description: row.description || ''
|
||
})
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!form.username.trim()) {
|
||
ElMessage.warning('请输入用户名')
|
||
return
|
||
}
|
||
if (!editingId.value && !form.password.trim()) {
|
||
ElMessage.warning('请输入密码')
|
||
return
|
||
}
|
||
saveLoading.value = true
|
||
try {
|
||
const data = { ...form }
|
||
if (!data.password) delete data.password
|
||
if (editingId.value) {
|
||
await usersStore.updateUser(editingId.value, data)
|
||
} else {
|
||
await usersStore.createUser(data)
|
||
}
|
||
ElMessage.success('保存成功')
|
||
dialogVisible.value = false
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleDelete(id) {
|
||
deleteId.value = id
|
||
deleteDialogVisible.value = true
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
await usersStore.deleteUser(deleteId.value)
|
||
ElMessage.success('删除成功')
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.users-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 5: 验证所有页面**
|
||
|
||
Run: 访问 /application, /approval, /logs, /users
|
||
Expected: 所有页面功能正常,权限控制生效
|
||
|
||
---
|
||
|
||
## 自检清单
|
||
|
||
### Spec 覆盖检查
|
||
- [ ] 8 个页面全部实现(Login, Materials, Inbound, Outbound, Stock, Application, Approval, Logs, Users)
|
||
- [ ] 路由守卫实现(authRequired, adminOnly, guestOnly)
|
||
- [ ] Element Plus UI 组件库使用
|
||
- [ ] Pinia 状态管理
|
||
- [ ] Axios API 层封装
|
||
- [ ] History 路由模式
|
||
|
||
### 占位符检查
|
||
- [ ] 无 "TBD"、"TODO" 等占位符
|
||
- [ ] 所有代码块完整可执行
|
||
|
||
### 类型一致性检查
|
||
- [ ] Store 的 state 和 actions 命名一致
|
||
- [ ] API 函数命名与后端接口对应
|
||
- [ ] 组件 props/emits 定义清晰
|
||
|
||
---
|
||
|
||
## 执行方式选择
|
||
|
||
**Plan complete and saved to `docs/superpowers/plans/2026-09-07-wmms-vue-migration-plan.md`.**
|
||
|
||
Two execution options:
|
||
|
||
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||
|
||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||
|
||
**Which approach?** |