84 KiB
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
{
"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
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
<!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
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
<template>
<router-view />
</template>
<script setup>
</script>
- Step 6: 创建基础路由文件 router/index.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 实例)
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
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
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
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
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
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
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
import request from './index'
export const logsApi = {
getAll(params = {}) {
return request.get('/logs', { params })
}
}
- Step 9: 创建 api/users.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
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
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
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
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
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
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
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
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
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
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
<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
<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 添加路由和守卫
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 添加布局
<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 基础版本
<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
<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
<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
<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
<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
<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
<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
<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
<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
<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
<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 结构)
<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(物料申请表单 + 我的申请列表)
<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(申请审批列表)
<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(日志记录)
<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(用户管理)
<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?