material_manage_system/docs/superpowers/plans/2026-09-07-wmms-vue-migration-plan.md
2026-09-15 16:08:18 +08:00

3136 lines
84 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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?**