添加后端基础架构和API功能

- 配置.env文件支持环境变量管理
- 集成MyBatis Plus ORM框架并配置数据库连接
- 实现JWT认证和Spring Security安全配置
- 添加全局异常处理机制和统一响应结果封装
- 配置CORS跨域资源共享支持
- 实现用户认证API包括登录、注册、刷新token功能
- 开发完整的药品管理系统包含分类、药品、库存管理
- 实现出入库管理功能包括供应商、客户管理
- 添加角色权限管理和菜单配置
- 实现仪表盘统计和过期预警功能
- 配置分页查询和数据验证功能
```
This commit is contained in:
2026-07-06 20:04:44 +08:00
parent 15da4fe492
commit 258bae75b2
102 changed files with 3054 additions and 128 deletions

View File

@@ -0,0 +1,2 @@
# 开发环境Vite dev 模式下自动加载)
VITE_API_BASE_URL=http://localhost:8080

6
frontend/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# ============================================================
# 前端环境变量Vite 原生支持,复制为 .env 即可生效)
# ============================================================
# API 代理目标地址
VITE_API_BASE_URL=http://localhost:8080

View File

@@ -12,6 +12,10 @@ export function getMenusApi() {
return request.get('/menus/tree')
}
export function registerApi(data) {
return request.post('/auth/register', data)
}
export function refreshTokenApi(data) {
return request.post('/auth/refresh', data)
}

View File

@@ -24,14 +24,17 @@ function renderMenu(menus) {
return menus
.filter((m) => m.type !== 3 && m.visible !== 0)
.map((menu) => {
if (menu.children?.length) {
// 递归处理子节点,过滤掉 type=3 后的有效子节点
const children = menu.children?.length ? renderMenu(menu.children) : []
if (children.length) {
return {
index: menu.path || menu.name,
title: menu.name,
icon: menu.icon,
children: renderMenu(menu.children),
children,
}
}
// 无有效子节点 → 渲染为可点击的叶子菜单项
return {
index: menu.path?.toLowerCase() || '',
title: menu.name,
@@ -41,6 +44,21 @@ function renderMenu(menus) {
}
const menuItems = computed(() => renderMenu(userStore.menus))
// 所有有子菜单的父级 index保持展开
const openedMenus = computed(() => {
const indices = []
function collect(menus) {
menus.forEach(m => {
if (m.children?.length) {
indices.push(m.index || m.name)
collect(m.children)
}
})
}
collect(userStore.menus)
return indices
})
</script>
<template>
@@ -48,27 +66,29 @@ const menuItems = computed(() => renderMenu(userStore.menus))
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="aside">
<div class="logo">
<span v-if="!isCollapse">💊 药品管理系统</span>
<span v-else>💊</span>
<span v-if="!isCollapse"> 药品管理系统</span>
<span v-else></span>
</div>
<el-menu
:key="menuItems.length"
:default-active="activeMenu"
:default-openeds="openedMenus"
:collapse="isCollapse"
:collapse-transition="false"
router
@select="(index) => { if (index) router.push(index) }"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<template v-for="item in menuItems" :key="item.index">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children" :index="item.index">
<el-sub-menu v-if="item.children?.length" :index="item.index">
<template #title>
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</template>
<template v-for="child in item.children" :key="child.index">
<el-sub-menu v-if="child.children" :index="child.index">
<el-sub-menu v-if="child.children?.length" :index="child.index">
<template #title>{{ child.title }}</template>
<el-menu-item
v-for="sub in child.children"

View File

@@ -10,6 +10,12 @@ const constantRoutes = [
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', noAuth: true },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/login/RegisterView.vue'),
meta: { title: '注册', noAuth: true },
},
{
path: '/',
name: 'MainLayout',
@@ -48,6 +54,7 @@ export const componentMap = {
'views/stockOut/StockOutForm': () => import('@/views/stockOut/StockOutForm.vue'),
'views/user/UserList': () => import('@/views/user/UserList.vue'),
'views/role/RoleList': () => import('@/views/role/RoleList.vue'),
'views/menu/MenuList': () => import('@/views/menu/MenuList.vue'),
}
// 根据后端菜单数据生成路由
@@ -79,13 +86,12 @@ router.beforeEach(async (to, _from, next) => {
return
}
// TODO: 暂时跳过登录验证(开发阶段)
// if (!userStore.token) {
// next('/login')
// return
// }
if (!userStore.token) {
next('/login')
return
}
// 动态注册菜单路由(仅首次),开发阶段使用本地 mock 菜单
// 动态注册菜单路由(仅首次)
if (!isMenusLoaded) {
try {
const menus = await userStore.getMenus()

View File

@@ -25,9 +25,9 @@ const mockMenus = [
]
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || 'dev-token') // TODO: 开发阶段默认有 token
const refreshToken = ref(localStorage.getItem('refreshToken') || 'dev-refresh')
const userInfo = ref({ username: 'admin', realName: '管理员' }) // TODO: 开发阶段 mock
const token = ref(localStorage.getItem('token') || '')
const refreshToken = ref(localStorage.getItem('refreshToken') || '')
const userInfo = ref({})
const menus = ref([])
const permissions = ref([])
@@ -35,7 +35,8 @@ export const useUserStore = defineStore('user', () => {
const perms = []
function walk(list) {
list.forEach((item) => {
if (item.permission_code) perms.push(item.permission_code)
const code = item.permissionCode || item.permission_code
if (code) perms.push(code)
if (item.children?.length) walk(item.children)
})
}

View File

@@ -4,6 +4,7 @@ import { getStatistics, getExpiryWarning } from '@/api/dashboard'
import { getStockInList } from '@/api/stockIn'
import { getStockOutList } from '@/api/stockOut'
import { Goods, Box, DocumentChecked, Warning } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const statistics = ref({ medicineCount: 0, stockBatchCount: 0, pendingStockIn: 0, expiryWarningCount: 0 })
const expiryList = ref([])
@@ -18,31 +19,30 @@ const statCards = [
]
function expiryColor(date) {
if (!date) return ''
const days = Math.ceil((new Date(date) - new Date()) / (1000 * 60 * 60 * 24))
if (days < 30) return 'danger'
if (days < 90) return 'warning'
return ''
return 'success'
}
onMounted(async () => {
try {
const [statRes, warnRes, inRes, outRes] = await Promise.all([
getStatistics(),
getExpiryWarning(),
const [statRes, warnRes, inRes, outRes] = await Promise.allSettled([
getStatistics(), getExpiryWarning(),
getStockInList({ page: 1, size: 5 }),
getStockOutList({ page: 1, size: 5 }),
])
statistics.value = statRes.data
expiryList.value = warnRes.data?.records || warnRes.data || []
recentInList.value = inRes.data?.records || []
recentOutList.value = outRes.data?.records || []
} catch { /* 后端未启动,静默处理 */ }
if (statRes.status === 'fulfilled') statistics.value = statRes.value.data || {}
if (warnRes.status === 'fulfilled') expiryList.value = warnRes.value.data?.records || warnRes.value.data || []
if (inRes.status === 'fulfilled') recentInList.value = inRes.value.data?.records || []
if (outRes.status === 'fulfilled') recentOutList.value = outRes.value.data?.records || []
} catch { /* 后端未启动 */ }
})
</script>
<template>
<div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div v-for="card in statCards" :key="card.key" class="stat-card">
<div class="stat-icon" :style="{ background: card.color }">
@@ -55,31 +55,27 @@ onMounted(async () => {
</div>
</div>
<!-- 效期预警 -->
<el-card class="section-card" header="效期预警近90天">
<el-table :data="expiryList" size="small" empty-text="暂无预警数据">
<el-table-column prop="medicineName" label="药品名称" />
<el-table-column prop="batchNo" label="批号" />
<el-card class="section-card" header="效期预警90天内到期">
<el-table :data="expiryList" size="small" empty-text="暂无预警">
<el-table-column prop="medicineName" label="药品" min-width="160" />
<el-table-column prop="batchNo" label="批号" width="120" />
<el-table-column prop="quantity" label="库存" width="80" />
<el-table-column prop="expiryDate" label="有效期至" width="120">
<el-table-column prop="expiryDate" label="有效期至" width="130">
<template #default="{ row }">
<el-tag :type="expiryColor(row.expiryDate || row.expiry_date)" size="small">
{{ row.expiryDate || row.expiry_date }}
</el-tag>
<el-tag :type="expiryColor(row.expiryDate)" size="small">{{ row.expiryDate }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 最近记录 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<el-card header="最近入库">
<template v-if="recentInList.length">
<div v-for="item in recentInList" :key="item.id" class="record-item">
<span>{{ item.recordNo || item.record_no }}</span>
<span>{{ item.supplierName || '供应商' }}</span>
<el-tag :type="item.status === 1 ? 'success' : 'warning'" size="small">
{{ item.status === 1 ? '已入库' : item.status === 0 ? '待审核' : '已取消' }}
<span>{{ item.recordNo }}</span>
<span style="color:#909399;font-size:13px">{{ item.supplierName }}</span>
<el-tag :type="item.status===1?'success':item.status===0?'warning':'info'" size="small">
{{ item.status===1?'已入库':item.status===0?'待审核':'已取消' }}
</el-tag>
</div>
</template>
@@ -88,10 +84,10 @@ onMounted(async () => {
<el-card header="最近出库">
<template v-if="recentOutList.length">
<div v-for="item in recentOutList" :key="item.id" class="record-item">
<span>{{ item.recordNo || item.record_no }}</span>
<span>{{ item.customerName || '客户' }}</span>
<el-tag :type="item.status === 1 ? 'success' : 'warning'" size="small">
{{ item.status === 1 ? '已出库' : item.status === 0 ? '待审核' : '已取消' }}
<span>{{ item.recordNo }}</span>
<span style="color:#909399;font-size:13px">{{ item.customerName || '-' }}</span>
<el-tag :type="item.status===1?'success':item.status===0?'warning':'info'" size="small">
{{ item.status===1?'已出库':item.status===0?'待审核':'已取消' }}
</el-tag>
</div>
</template>
@@ -102,19 +98,10 @@ onMounted(async () => {
</template>
<style lang="scss" scoped>
.section-card {
margin-bottom: 16px;
}
.section-card { margin-bottom: 16px; }
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid #f0f0f0;
&:last-child { border-bottom: none; }
}
</style>

View File

@@ -70,15 +70,13 @@ async function handleLogin() {
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
<el-button type="primary" :loading="loading" style="width:100%" @click="handleLogin">
{{ loading ? '登录中...' : ' ' }}
</el-button>
</el-form-item>
<el-form-item>
<el-button style="width:100%" @click="router.push('/register')">没有账号去注册</el-button>
</el-form-item>
</el-form>
</div>
</div>

View File

@@ -0,0 +1,117 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { registerApi } from '@/api/auth'
import { ElMessage } from 'element-plus'
import { User, Lock, Phone, Message } from '@element-plus/icons-vue'
const router = useRouter()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
username: '',
password: '',
confirmPassword: '',
realName: '',
phone: '',
email: '',
})
const validatePass = (_rule, value, callback) => {
if (value !== form.password) callback(new Error('两次密码不一致'))
else callback()
}
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 32, message: '用户名长度3-32位', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 32, message: '密码长度6-32位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validatePass, trigger: 'blur' },
],
}
async function handleRegister() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await registerApi({
username: form.username,
password: form.password,
realName: form.realName,
phone: form.phone,
email: form.email,
})
ElMessage.success('注册成功,请登录')
router.push('/login')
} catch { /* error handled by interceptor */ }
finally { loading.value = false }
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<h2 class="login-title">注册账号</h2>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @keyup.enter="handleRegister">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="realName">
<el-input v-model="form.realName" placeholder="真实姓名(选填)" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="密码" :prefix-icon="Lock" />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="确认密码" :prefix-icon="Lock" />
</el-form-item>
<el-form-item prop="phone">
<el-input v-model="form.phone" placeholder="手机号(选填)" :prefix-icon="Phone" />
</el-form-item>
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱(选填)" :prefix-icon="Message" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="handleRegister">
{{ loading ? '注册中...' : ' ' }}
</el-button>
</el-form-item>
<el-form-item>
<el-button style="width:100%" @click="router.push('/login')">已有账号去登录</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 420px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.login-title {
text-align: center;
margin-bottom: 24px;
color: #303133;
font-size: 22px;
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
const tableData = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增菜单')
const formRef = ref(null)
const parentOptions = ref([])
const form = reactive({
id: null, parentId: 0, name: '', type: 1, path: '', component: '',
icon: '', permissionCode: '', sortOrder: 0, visible: 1,
})
async function loadData() {
try {
const res = await request.get('/menus')
tableData.value = buildTree(res.data || [])
parentOptions.value = flatten(res.data || [])
} catch { /* */ }
}
function buildTree(list, parentId = 0) {
return list.filter(m => m.parentId === parentId).map(m => ({
...m, children: buildTree(list, m.id) || undefined,
}))
}
function flatten(list) {
return list.map(m => ({ id: m.id, name: m.name, type: m.type }))
}
function handleAdd(parentId = 0) {
dialogTitle.value = '新增菜单'
Object.keys(form).forEach(k => form[k] = null)
form.id = null; form.parentId = parentId; form.type = 1; form.sortOrder = 0; form.visible = 1
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑菜单'
Object.assign(form, row)
form.parentId = form.parentId || 0
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除"${row.name}"?子菜单也会被删除。`, '确认删除', { type: 'warning' })
await request.delete(`/menus/${row.id}`)
ElMessage.success('删除成功')
loadData()
}
async function handleSubmit() {
const data = { ...form }
delete data.id; delete data.children
if (form.id) {
await request.put(`/menus/${form.id}`, data)
ElMessage.success('编辑成功')
} else {
await request.post('/menus', data)
ElMessage.success('新增成功')
}
dialogVisible.value = false
loadData()
}
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="tool-bar">
<el-button type="primary" v-permission="'menu:add'" :icon="Plus" @click="handleAdd(0)">新增顶级菜单</el-button>
</div>
<el-table :data="tableData" border stripe row-key="id" default-expand-all>
<el-table-column prop="name" label="菜单名称" min-width="180" />
<el-table-column prop="type" label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.type === 1 ? '' : row.type === 2 ? 'success' : 'warning'" size="small">
{{ { 1: '目录', 2: '菜单', 3: '按钮' }[row.type] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" width="150" />
<el-table-column prop="component" label="组件路径" width="200" show-overflow-tooltip />
<el-table-column prop="icon" label="图标" width="100" />
<el-table-column prop="permissionCode" label="权限编码" width="160" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="70" align="center" />
<el-table-column prop="visible" label="可见" width="70" align="center">
<template #default="{ row }">{{ row.visible === 1 ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" v-permission="'menu:edit'" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" v-permission="'menu:add'" @click="handleAdd(row.id)">添加子级</el-button>
<el-button size="small" type="danger" v-permission="'menu:delete'" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" label-width="90px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="上级菜单">
<el-select v-model="form.parentId" placeholder="不选则为顶级" clearable style="width:100%">
<el-option v-for="p in parentOptions.filter(x => x.id !== form.id && x.type !== 3)" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单类型">
<el-select v-model="form.type" style="width:100%">
<el-option label="目录" :value="1" />
<el-option label="菜单" :value="2" />
<el-option label="按钮" :value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="图标"><el-input v-model="form.icon" placeholder="Element Plus 图标名" /></el-form-item>
</el-col>
<el-col v-if="form.type !== 1" :span="12">
<el-form-item label="路由路径"><el-input v-model="form.path" placeholder="type=2时填写如 /user/list" /></el-form-item>
</el-col>
<el-col v-if="form.type !== 1" :span="12">
<el-form-item label="组件路径"><el-input v-model="form.component" placeholder="type=2时填写" /></el-form-item>
</el-col>
<el-col v-if="form.type === 3" :span="12">
<el-form-item label="权限编码"><el-input v-model="form.permissionCode" placeholder="如 user:add" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="可见"><el-switch v-model="form.visible" :active-value="1" :inactive-value="0" /></el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="handleSubmit">确定</el-button></template>
</el-dialog>
</div>
</template>

View File

@@ -1,26 +1,53 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getStockInList, auditStockIn } from '@/api/stockIn'
import { getStockInList, getStockInById, auditStockIn } from '@/api/stockIn'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const query = reactive({ page: 1, size: 10 })
const query = reactive({ page: 1, size: 10, recordNo: '' })
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detailRecord = ref({})
const detailItems = ref([])
function statusTag(status) {
return { 0: 'warning', 1: 'success', 2: 'info' }[status] || 'info'
}
function statusText(status) {
return { 0: '待审核', 1: '已入库', 2: '已取消' }[status] || '未知'
const statusMap = { 0: { type: 'warning', text: '待审核' }, 1: { type: 'success', text: '已入库' }, 2: { type: 'info', text: '已取消' } }
async function loadData() {
loading.value = true
try {
const r = await getStockInList(query)
tableData.value = r.data?.records || []
total.value = r.data?.total || 0
} finally { loading.value = false }
}
async function loadData() { try { const r = await getStockInList(query); tableData.value = r.data?.records || []; total.value = r.data?.total || 0 } catch { /* */ } }
function handleCreate() { router.push('/stock-in/add') }
function handleView(row) { /* 查看详情弹窗 */ ElMessage.info('入库单号: ' + (row.recordNo || row.record_no)) }
async function handleAudit(row) { await auditStockIn(row.id, { status: 1 }); ElMessage.success('审核通过,库存已更新'); loadData() }
async function handleView(row) {
try {
const r = await getStockInById(row.id)
detailRecord.value = r.data || row
detailItems.value = r.data?.details || []
detailVisible.value = true
} catch { ElMessage.error('获取详情失败') }
}
async function handleAudit(row) {
await ElMessageBox.confirm(`确认审核通过入库单 ${row.recordNo}?审核后库存将自动更新。`, '审核确认', { type: 'success' })
await auditStockIn(row.id, { status: 1 })
ElMessage.success('审核通过,库存已更新')
loadData()
}
async function handleReject(row) {
await ElMessageBox.confirm(`确认驳回入库单 ${row.recordNo}`, '驳回确认', { type: 'warning' })
await auditStockIn(row.id, { status: 2 })
ElMessage.success('已驳回')
loadData()
}
onMounted(loadData)
</script>
@@ -28,30 +55,72 @@ onMounted(loadData)
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.recordNo" placeholder="入库单号" clearable style="width:180px" />
<el-input v-model="query.recordNo" placeholder="入库单号" clearable style="width:200px" @keyup.enter="loadData" />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="Object.keys(query).forEach(k=>query[k]=k==='page'?1:k==='size'?10:'');loadData()">重置</el-button>
<el-button :icon="Refresh" @click="query.recordNo='';loadData()">重置</el-button>
</div>
<div class="tool-bar">
<el-button type="primary" v-permission="'stock-in:add'" :icon="Plus" @click="handleCreate">+ 新建入库单</el-button>
<el-button type="primary" v-permission="'stock-in:add'" :icon="Plus" @click="handleCreate">新建入库单</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="recordNo" label="入库单号" width="150" />
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="recordNo" label="入库单号" width="160" />
<el-table-column prop="supplierName" label="供应商" min-width="180" />
<el-table-column prop="totalAmount" label="总金额" width="110"><template #default="{row}">¥{{row.totalAmount}}</template></el-table-column>
<el-table-column prop="totalAmount" label="总金额" width="120">
<template #default="{ row }">¥{{ row.totalAmount }}</template>
</el-table-column>
<el-table-column prop="inDate" label="入库日期" width="120" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{row}"><el-tag :type="statusTag(row.status)" size="small">{{statusText(row.status)}}</el-tag></template>
<template #default="{ row }">
<el-tag :type="statusMap[row.status]?.type || 'info'" size="small">
{{ statusMap[row.status]?.text || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{row}">
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" @click="handleView(row)">查看</el-button>
<el-button v-if="row.status===0" size="small" type="success" v-permission="'stock-in:audit'" @click="handleAudit(row)">审核</el-button>
<template v-if="row.status === 0">
<el-button size="small" type="success" v-permission="'stock-in:audit'" @click="handleAudit(row)">通过</el-button>
<el-button size="small" type="danger" @click="handleReject(row)">驳回</el-button>
</template>
</div>
</template>
</el-table-column>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:16px"><el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" @current-change="loadData" @size-change="query.page=1;loadData()" /></div>
<div style="display:flex;justify-content:flex-end;margin-top:16px">
<el-pagination
v-model:current-page="query.page" v-model:page-size="query.size"
:total="total" :page-sizes="[10,20,50]"
layout="total,sizes,prev,pager,next"
@current-change="loadData" @size-change="query.page=1;loadData()"
/>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="入库单详情" width="750px">
<el-descriptions :column="3" border>
<el-descriptions-item label="入库单号">{{ detailRecord.recordNo }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ detailRecord.supplierName }}</el-descriptions-item>
<el-descriptions-item label="入库日期">{{ detailRecord.inDate }}</el-descriptions-item>
<el-descriptions-item label="总金额">¥{{ detailRecord.totalAmount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusMap[detailRecord.status]?.type" size="small">{{ statusMap[detailRecord.status]?.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注">{{ detailRecord.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:16px 0 8px">药品明细</h4>
<el-table :data="detailItems" border size="small" max-height="300">
<el-table-column prop="medicineName" label="药品" min-width="160" />
<el-table-column prop="batchNo" label="批号" width="120" />
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="costPrice" label="进价" width="90"><template #default="{row}">¥{{row.costPrice}}</template></el-table-column>
<el-table-column prop="totalPrice" label="小计" width="100"><template #default="{row}">¥{{row.totalPrice}}</template></el-table-column>
<el-table-column prop="expiryDate" label="有效期至" width="120" />
</el-table>
</el-dialog>
</div>
</template>

View File

@@ -1,23 +1,54 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getStockOutList, auditStockOut } from '@/api/stockOut'
import { getStockOutList, getStockOutById, auditStockOut } from '@/api/stockOut'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const query = reactive({ page: 1, size: 10 })
const query = reactive({ page: 1, size: 10, recordNo: '' })
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detailRecord = ref({})
const detailItems = ref([])
function statusTag(s) { return { 0: 'warning', 1: 'success', 2: 'info' }[s] || 'info' }
function statusText(s) { return { 0: '待审核', 1: '出库', 2: '已取消' }[s] || '未知' }
function typeText(s) { return { 1: '销售出库', 2: '领用出库', 3: '报损出库', 4: '退货出库' }[s] || '未知' }
const statusMap = { 0: { type: 'warning', text: '待审核' }, 1: { type: 'success', text: '已出库' }, 2: { type: 'info', text: '已取消' } }
const typeMap = { 1: '销售出库', 2: '领用出库', 3: '报损出库', 4: '退货出库' }
async function loadData() {
loading.value = true
try {
const r = await getStockOutList(query)
tableData.value = r.data?.records || []
total.value = r.data?.total || 0
} finally { loading.value = false }
}
async function loadData() { try { const r = await getStockOutList(query); tableData.value = r.data?.records || []; total.value = r.data?.total || 0 } catch { /* */ } }
function handleCreate() { router.push('/stock-out/add') }
function handleView(row) { ElMessage.info('出库单号: ' + (row.recordNo || row.record_no)) }
async function handleAudit(row) { await auditStockOut(row.id, { status: 1 }); ElMessage.success('审核通过,库存已扣减'); loadData() }
async function handleView(row) {
try {
const r = await getStockOutById(row.id)
detailRecord.value = r.data || row
detailItems.value = r.data?.details || []
detailVisible.value = true
} catch { ElMessage.error('获取详情失败') }
}
async function handleAudit(row) {
await ElMessageBox.confirm(`确认审核通过出库单 ${row.recordNo}?审核后库存将自动扣减。`, '审核确认', { type: 'success' })
await auditStockOut(row.id, { status: 1 })
ElMessage.success('审核通过,库存已扣减')
loadData()
}
async function handleReject(row) {
await ElMessageBox.confirm(`确认驳回出库单 ${row.recordNo}`, '驳回确认', { type: 'warning' })
await auditStockOut(row.id, { status: 2 })
ElMessage.success('已驳回')
loadData()
}
onMounted(loadData)
</script>
@@ -25,31 +56,75 @@ onMounted(loadData)
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.recordNo" placeholder="出库单号" clearable style="width:180px" />
<el-input v-model="query.recordNo" placeholder="出库单号" clearable style="width:200px" @keyup.enter="loadData" />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="Object.keys(query).forEach(k=>query[k]=k==='page'?1:k==='size'?10:'');loadData()">重置</el-button>
<el-button :icon="Refresh" @click="query.recordNo='';loadData()">重置</el-button>
</div>
<div class="tool-bar">
<el-button type="primary" v-permission="'stock-out:add'" :icon="Plus" @click="handleCreate">+ 新建出库单</el-button>
<el-button type="primary" v-permission="'stock-out:add'" :icon="Plus" @click="handleCreate">新建出库单</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="recordNo" label="出库单号" width="150" />
<el-table-column prop="customerName" label="客户" min-width="160" />
<el-table-column prop="outType" label="类型" width="100"><template #default="{row}">{{typeText(row.outType)}}</template></el-table-column>
<el-table-column prop="totalAmount" label="总金额" width="110"><template #default="{row}">¥{{row.totalAmount}}</template></el-table-column>
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="recordNo" label="出库单号" width="160" />
<el-table-column prop="customerName" label="客户" min-width="160">
<template #default="{ row }">{{ row.customerName || '-' }}</template>
</el-table-column>
<el-table-column prop="outType" label="类型" width="110">
<template #default="{ row }">{{ typeMap[row.outType] || '未知' }}</template>
</el-table-column>
<el-table-column prop="totalAmount" label="总金额" width="120">
<template #default="{ row }">¥{{ row.totalAmount }}</template>
</el-table-column>
<el-table-column prop="outDate" label="出库日期" width="120" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{row}"><el-tag :type="statusTag(row.status)" size="small">{{statusText(row.status)}}</el-tag></template>
<template #default="{ row }">
<el-tag :type="statusMap[row.status]?.type || 'info'" size="small">
{{ statusMap[row.status]?.text || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{row}">
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" @click="handleView(row)">查看</el-button>
<el-button v-if="row.status===0" size="small" type="success" v-permission="'stock-out:audit'" @click="handleAudit(row)">审核</el-button>
<template v-if="row.status === 0">
<el-button size="small" type="success" v-permission="'stock-out:audit'" @click="handleAudit(row)">通过</el-button>
<el-button size="small" type="danger" @click="handleReject(row)">驳回</el-button>
</template>
</div>
</template>
</el-table-column>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:16px"><el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" @current-change="loadData" @size-change="query.page=1;loadData()" /></div>
<div style="display:flex;justify-content:flex-end;margin-top:16px">
<el-pagination
v-model:current-page="query.page" v-model:page-size="query.size"
:total="total" :page-sizes="[10,20,50]"
layout="total,sizes,prev,pager,next"
@current-change="loadData" @size-change="query.page=1;loadData()"
/>
</div>
<el-dialog v-model="detailVisible" title="出库单详情" width="750px">
<el-descriptions :column="3" border>
<el-descriptions-item label="出库单号">{{ detailRecord.recordNo }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detailRecord.customerName || '-' }}</el-descriptions-item>
<el-descriptions-item label="出库日期">{{ detailRecord.outDate }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ typeMap[detailRecord.outType] }}</el-descriptions-item>
<el-descriptions-item label="总金额">¥{{ detailRecord.totalAmount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusMap[detailRecord.status]?.type" size="small">{{ statusMap[detailRecord.status]?.text }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<h4 style="margin:16px 0 8px">药品明细</h4>
<el-table :data="detailItems" border size="small" max-height="300">
<el-table-column prop="medicineName" label="药品" min-width="160" />
<el-table-column prop="batchNo" label="批号" width="120" />
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="unitPrice" label="单价" width="90"><template #default="{row}">¥{{row.unitPrice}}</template></el-table-column>
<el-table-column prop="totalPrice" label="小计" width="100"><template #default="{row}">¥{{row.totalPrice}}</template></el-table-column>
</el-table>
</el-dialog>
</div>
</template>

View File

@@ -1,22 +1,25 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true,
},
},
},
}
})