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

84 KiB
Raw Permalink Blame History

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?