添加后端基础架构和API功能
- 配置.env文件支持环境变量管理 - 集成MyBatis Plus ORM框架并配置数据库连接 - 实现JWT认证和Spring Security安全配置 - 添加全局异常处理机制和统一响应结果封装 - 配置CORS跨域资源共享支持 - 实现用户认证API包括登录、注册、刷新token功能 - 开发完整的药品管理系统包含分类、药品、库存管理 - 实现出入库管理功能包括供应商、客户管理 - 添加角色权限管理和菜单配置 - 实现仪表盘统计和过期预警功能 - 配置分页查询和数据验证功能 ```
This commit is contained in:
2
frontend/.env.development
Normal file
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 开发环境(Vite dev 模式下自动加载)
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
6
frontend/.env.example
Normal file
6
frontend/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# ============================================================
|
||||
# 前端环境变量(Vite 原生支持,复制为 .env 即可生效)
|
||||
# ============================================================
|
||||
|
||||
# API 代理目标地址
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
117
frontend/src/views/login/RegisterView.vue
Normal file
117
frontend/src/views/login/RegisterView.vue
Normal 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>
|
||||
153
frontend/src/views/menu/MenuList.vue
Normal file
153
frontend/src/views/menu/MenuList.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user