完善前端项目结构和依赖配置

- 更新 package.json 中的依赖版本号,包括 element-plus、axios、pinia、vue-router 等
- 删除 HelloWorld 组件,替换为 router-view 以支持路由功能
- 添加 auth、category、customer、dashboard、medicine、role、stock、stockIn、stockOut、
  supplier、user 等多个 API 接口文件
- 创建权限指令、主布局组件和全局样式
- 集成 Element Plus UI 框架并注册图标组件
- 实现应用状态管理和用户状态管理 Store
- 配置 Vue Router 路由系统,支持动态菜单路由生成
- 移除旧的 CSS 样式文件,改用 SCSS 全局样式
```
This commit is contained in:
2026-07-06 12:07:23 +08:00
parent 3fcf927aef
commit 15da4fe492
38 changed files with 2057 additions and 409 deletions

View File

@@ -8,16 +8,16 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.x",
"axios": "^1.x",
"element-plus": "^2.x",
"pinia": "^2.x",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"element-plus": "^2.14.2",
"pinia": "^2.3.1",
"vue": "^3.5.39",
"vue-router": "^4.x"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"sass": "^1.x",
"sass": "^1.101.0",
"vite": "^8.1.1"
}
},
@@ -700,6 +700,29 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
@@ -1755,7 +1778,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -9,16 +9,16 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"element-plus": "^2.14.2",
"pinia": "^2.3.1",
"vue": "^3.5.39",
"vue-router": "^4.x",
"pinia": "^2.x",
"axios": "^1.x",
"element-plus": "^2.x",
"@element-plus/icons-vue": "^2.x"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"vite": "^8.1.1",
"sass": "^1.x"
"sass": "^1.101.0",
"vite": "^8.1.1"
}
}

View File

@@ -1,7 +1,6 @@
<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<HelloWorld />
<router-view />
</template>

17
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function loginApi(data) {
return request.post('/auth/login', data)
}
export function getUserInfoApi() {
return request.get('/auth/userinfo')
}
export function getMenusApi() {
return request.get('/menus/tree')
}
export function refreshTokenApi(data) {
return request.post('/auth/refresh', data)
}

View File

@@ -0,0 +1,21 @@
import request from '@/utils/request'
export function getCategoryList(params) {
return request.get('/categories', { params })
}
export function getCategoryTree() {
return request.get('/categories/tree')
}
export function createCategory(data) {
return request.post('/categories', data)
}
export function updateCategory(id, data) {
return request.put(`/categories/${id}`, data)
}
export function deleteCategory(id) {
return request.delete(`/categories/${id}`)
}

View File

@@ -0,0 +1,21 @@
import request from '@/utils/request'
export function getCustomerList(params) {
return request.get('/customers', { params })
}
export function getAllCustomers() {
return request.get('/customers/all')
}
export function createCustomer(data) {
return request.post('/customers', data)
}
export function updateCustomer(id, data) {
return request.put(`/customers/${id}`, data)
}
export function deleteCustomer(id) {
return request.delete(`/customers/${id}`)
}

View File

@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function getStatistics() {
return request.get('/dashboard/statistics')
}
export function getExpiryWarning() {
return request.get('/dashboard/expiry-warning')
}

View File

@@ -0,0 +1,21 @@
import request from '@/utils/request'
export function getMedicineList(params) {
return request.get('/medicines', { params })
}
export function getMedicineById(id) {
return request.get(`/medicines/${id}`)
}
export function createMedicine(data) {
return request.post('/medicines', data)
}
export function updateMedicine(id, data) {
return request.put(`/medicines/${id}`, data)
}
export function deleteMedicine(id) {
return request.delete(`/medicines/${id}`)
}

25
frontend/src/api/role.js Normal file
View File

@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function getRoleList(params) {
return request.get('/roles', { params })
}
export function getAllRoles() {
return request.get('/roles/all')
}
export function createRole(data) {
return request.post('/roles', data)
}
export function updateRole(id, data) {
return request.put(`/roles/${id}`, data)
}
export function deleteRole(id) {
return request.delete(`/roles/${id}`)
}
export function assignMenus(id, data) {
return request.put(`/roles/${id}/menus`, data)
}

17
frontend/src/api/stock.js Normal file
View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function getStockList(params) {
return request.get('/stocks', { params })
}
export function getStockWarning(params) {
return request.get('/stocks/warning', { params })
}
export function getStockById(id) {
return request.get(`/stocks/${id}`)
}
export function stockCheck(data) {
return request.post('/stocks/check', data)
}

View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function getStockInList(params) {
return request.get('/stock-in', { params })
}
export function getStockInById(id) {
return request.get(`/stock-in/${id}`)
}
export function createStockIn(data) {
return request.post('/stock-in', data)
}
export function auditStockIn(id, data) {
return request.put(`/stock-in/${id}/status`, data)
}

View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function getStockOutList(params) {
return request.get('/stock-out', { params })
}
export function getStockOutById(id) {
return request.get(`/stock-out/${id}`)
}
export function createStockOut(data) {
return request.post('/stock-out', data)
}
export function auditStockOut(id, data) {
return request.put(`/stock-out/${id}/status`, data)
}

View File

@@ -0,0 +1,21 @@
import request from '@/utils/request'
export function getSupplierList(params) {
return request.get('/suppliers', { params })
}
export function getAllSuppliers() {
return request.get('/suppliers/all')
}
export function createSupplier(data) {
return request.post('/suppliers', data)
}
export function updateSupplier(id, data) {
return request.put(`/suppliers/${id}`, data)
}
export function deleteSupplier(id) {
return request.delete(`/suppliers/${id}`)
}

25
frontend/src/api/user.js Normal file
View File

@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function getUserList(params) {
return request.get('/users', { params })
}
export function createUser(data) {
return request.post('/users', data)
}
export function updateUser(id, data) {
return request.put(`/users/${id}`, data)
}
export function deleteUser(id) {
return request.delete(`/users/${id}`)
}
export function resetPassword(id, data) {
return request.put(`/users/${id}/password`, data)
}
export function assignRoles(id, data) {
return request.put(`/users/${id}/roles`, data)
}

View File

@@ -1,95 +0,0 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

@@ -0,0 +1,10 @@
import { useUserStore } from '@/stores/user'
export default {
mounted(el, binding) {
const userStore = useUserStore()
if (!userStore.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
}

View File

@@ -0,0 +1,202 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { Fold, Expand, User, ArrowDown, SwitchButton } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const appStore = useAppStore()
const activeMenu = computed(() => route.path)
const isCollapse = computed(() => appStore.sidebarCollapsed)
function handleCommand(command) {
if (command === 'logout') {
userStore.logout()
}
}
// 递归渲染侧边栏菜单
function renderMenu(menus) {
return menus
.filter((m) => m.type !== 3 && m.visible !== 0)
.map((menu) => {
if (menu.children?.length) {
return {
index: menu.path || menu.name,
title: menu.name,
icon: menu.icon,
children: renderMenu(menu.children),
}
}
return {
index: menu.path?.toLowerCase() || '',
title: menu.name,
icon: menu.icon,
}
})
}
const menuItems = computed(() => renderMenu(userStore.menus))
</script>
<template>
<el-container class="layout">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="aside">
<div class="logo">
<span v-if="!isCollapse">💊 药品管理系统</span>
<span v-else>💊</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:collapse-transition="false"
router
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">
<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">
<template #title>{{ child.title }}</template>
<el-menu-item
v-for="sub in child.children"
:key="sub.index"
:index="sub.index"
>
{{ sub.title }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="child.index">
{{ child.title }}
</el-menu-item>
</template>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="item.index">
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- 右侧主体 -->
<el-container>
<!-- 顶部栏 -->
<el-header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="appStore.toggleSidebar">
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">
{{ route.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
{{ userStore.userInfo.realName || userStore.userInfo.username }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>个人信息
</el-dropdown-item>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 内容区 -->
<el-main class="main">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<style lang="scss" scoped>
.layout {
height: 100vh;
}
.aside {
background-color: #304156;
transition: width 0.3s;
overflow-x: hidden;
.logo {
height: 60px;
line-height: 60px;
text-align: center;
color: #fff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
white-space: nowrap;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-bottom: 1px solid #e6e6e6;
padding: 0 20px;
height: 60px;
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
&:hover {
color: #409eff;
}
}
.user-info {
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
}
.main {
background: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
</style>

View File

@@ -1,5 +1,22 @@
import { createApp } from 'vue'
import './style.css'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import permission from './directives/permission'
import './styles/global.scss'
createApp(App).mount('#app')
const app = createApp(App)
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { size: 'default' })
app.directive('permission', permission)
app.mount('#app')

View File

@@ -0,0 +1,109 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import MainLayout from '@/layout/MainLayout.vue'
// 静态路由(无需权限)
const constantRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', noAuth: true },
},
{
path: '/',
name: 'MainLayout',
component: MainLayout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: '仪表盘', icon: 'Odometer' },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/dashboard',
},
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
})
// 动态路由映射(后端菜单 component 路径 → 页面组件)
export const componentMap = {
'views/medicine/MedicineList': () => import('@/views/medicine/MedicineList.vue'),
'views/category/CategoryList': () => import('@/views/category/CategoryList.vue'),
'views/supplier/SupplierList': () => import('@/views/supplier/SupplierList.vue'),
'views/customer/CustomerList': () => import('@/views/customer/CustomerList.vue'),
'views/stock/StockList': () => import('@/views/stock/StockList.vue'),
'views/stockIn/StockInList': () => import('@/views/stockIn/StockInList.vue'),
'views/stockIn/StockInForm': () => import('@/views/stockIn/StockInForm.vue'),
'views/stockOut/StockOutList': () => import('@/views/stockOut/StockOutList.vue'),
'views/stockOut/StockOutForm': () => import('@/views/stockOut/StockOutForm.vue'),
'views/user/UserList': () => import('@/views/user/UserList.vue'),
'views/role/RoleList': () => import('@/views/role/RoleList.vue'),
}
// 根据后端菜单数据生成路由
function generateRoutes(menus) {
const routes = []
menus.forEach((menu) => {
if (menu.type === 2 && menu.path && menu.component) {
routes.push({
path: menu.path.toLowerCase(),
name: menu.name,
component: componentMap[menu.component] || null,
meta: { title: menu.name, icon: menu.icon },
})
}
if (menu.children?.length) {
routes.push(...generateRoutes(menu.children))
}
})
return routes
}
let isMenusLoaded = false
router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
if (to.meta.noAuth) {
next()
return
}
// TODO: 暂时跳过登录验证(开发阶段)
// if (!userStore.token) {
// next('/login')
// return
// }
// 动态注册菜单路由(仅首次),开发阶段使用本地 mock 菜单
if (!isMenusLoaded) {
try {
const menus = await userStore.getMenus()
if (menus.length) {
const dynamicRoutes = generateRoutes(menus)
dynamicRoutes.forEach((route) => {
router.addRoute('MainLayout', route)
})
}
} catch {
// 后端未启动,跳过
}
isMenusLoaded = true
next({ ...to, replace: true })
return
}
next()
})
export default router

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
return { sidebarCollapsed, toggleSidebar }
})

View File

@@ -0,0 +1,96 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { loginApi, getUserInfoApi, getMenusApi } from '@/api/auth'
// 开发阶段本地 mock 菜单
const mockMenus = [
{ id: 55, parentId: 0, name: '仪表盘', type: 2, path: '/dashboard', component: 'views/dashboard/DashboardView', icon: 'Odometer', permission_code: null, sortOrder: 0, visible: 1, children: [] },
{ id: 21, parentId: 0, name: '药品管理', type: 1, path: null, component: null, icon: 'Goods', permission_code: null, sortOrder: 2, visible: 1, children: [
{ id: 22, parentId: 21, name: '药品列表', type: 2, path: '/medicine/list', component: 'views/medicine/MedicineList', icon: null, permission_code: 'medicine:list', sortOrder: 21, visible: 1, children: [] },
{ id: 27, parentId: 21, name: '药品分类', type: 2, path: '/category/list', component: 'views/category/CategoryList', icon: null, permission_code: 'category:list', sortOrder: 22, visible: 1, children: [] },
]},
{ id: 32, parentId: 0, name: '库存管理', type: 1, path: null, component: null, icon: 'Box', permission_code: null, sortOrder: 3, visible: 1, children: [
{ id: 33, parentId: 32, name: '库存列表', type: 2, path: '/stock/list', component: 'views/stock/StockList', icon: null, permission_code: 'stock:list', sortOrder: 31, visible: 1, children: [] },
{ id: 36, parentId: 32, name: '入库管理', type: 2, path: '/stock-in/list', component: 'views/stockIn/StockInList', icon: null, permission_code: 'stock-in:list', sortOrder: 32, visible: 1, children: [] },
{ id: 40, parentId: 32, name: '出库管理', type: 2, path: '/stock-out/list', component: 'views/stockOut/StockOutList', icon: null, permission_code: 'stock-out:list', sortOrder: 33, visible: 1, children: [] },
]},
{ id: 44, parentId: 0, name: '往来单位', type: 1, path: null, component: null, icon: 'Connection', permission_code: null, sortOrder: 4, visible: 1, children: [
{ id: 45, parentId: 44, name: '供应商管理', type: 2, path: '/supplier/list', component: 'views/supplier/SupplierList', icon: null, permission_code: 'supplier:list', sortOrder: 41, visible: 1, children: [] },
{ id: 50, parentId: 44, name: '客户管理', type: 2, path: '/customer/list', component: 'views/customer/CustomerList', icon: null, permission_code: 'customer:list', sortOrder: 42, visible: 1, children: [] },
]},
{ id: 1, parentId: 0, name: '系统管理', type: 1, path: null, component: null, icon: 'Setting', permission_code: null, sortOrder: 1, visible: 1, children: [
{ id: 2, parentId: 1, name: '用户管理', type: 2, path: '/user/list', component: 'views/user/UserList', icon: null, permission_code: 'user:list', sortOrder: 11, visible: 1, children: [] },
{ id: 10, parentId: 1, name: '角色管理', type: 2, path: '/role/list', component: 'views/role/RoleList', icon: null, permission_code: 'role:list', sortOrder: 12, visible: 1, children: [] },
]},
]
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 menus = ref([])
const permissions = ref([])
function extractPermissions(menuList) {
const perms = []
function walk(list) {
list.forEach((item) => {
if (item.permission_code) perms.push(item.permission_code)
if (item.children?.length) walk(item.children)
})
}
walk(menuList)
return perms
}
async function login(credentials) {
const res = await loginApi(credentials)
token.value = res.data.token
refreshToken.value = res.data.refreshToken
localStorage.setItem('token', token.value)
localStorage.setItem('refreshToken', refreshToken.value)
await loadUserInfo()
await loadMenus()
}
async function loadUserInfo() {
const res = await getUserInfoApi()
userInfo.value = res.data
}
async function loadMenus() {
const res = await getMenusApi()
menus.value = res.data
permissions.value = extractPermissions(res.data)
}
async function getMenus() {
if (!menus.value.length) {
// 尝试从后端获取,失败则用本地 mock
try {
await loadMenus()
} catch {
menus.value = mockMenus
permissions.value = extractPermissions(mockMenus)
}
}
return menus.value
}
function logout() {
token.value = ''
refreshToken.value = ''
userInfo.value = {}
menus.value = []
permissions.value = []
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
router.push('/login')
}
function hasPermission(perm) {
return permissions.value.includes(perm)
}
return { token, refreshToken, userInfo, menus, permissions, login, logout, getMenus, loadUserInfo, hasPermission }
})

View File

@@ -1,296 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -0,0 +1,96 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', Arial, sans-serif;
}
// 页面通用样式
.page-container {
background: #fff;
border-radius: 4px;
padding: 20px;
min-height: calc(100vh - 140px);
}
.search-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 4px;
.el-input, .el-select, .el-cascader, .el-date-picker {
width: 200px;
}
}
.tool-bar {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
}
// 表格操作按钮
.table-actions {
display: flex;
gap: 8px;
}
// 弹窗表单
.dialog-form {
.el-form-item {
margin-bottom: 18px;
}
.el-input, .el-select, .el-cascader {
width: 100%;
}
}
// 统计卡片
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
}
.stat-info {
.stat-label {
color: #909399;
font-size: 14px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #303133;
}
}
}

View File

@@ -0,0 +1,61 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: '/api',
timeout: 15000,
})
// 请求拦截器 — 注入 Token
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器 — 统一处理
request.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res
},
async (error) => {
if (error.response?.status === 401) {
const userStore = useUserStore()
// 尝试刷新 Token
try {
const res = await axios.post('/api/auth/refresh', {
refreshToken: userStore.refreshToken,
})
if (res.data.code === 200) {
userStore.token = res.data.data.token
error.config.headers.Authorization = `Bearer ${res.data.data.token}`
return request(error.config)
}
} catch {
// 刷新失败,跳转登录
}
userStore.logout()
ElMessage.error('登录已过期,请重新登录')
} else if (error.response?.status === 403) {
ElMessage.error('权限不足')
} else if (error.response?.status === 500) {
ElMessage.error('服务器内部错误')
} else {
ElMessage.error(error.message || '网络错误')
}
return Promise.reject(error)
}
)
export default request

View File

@@ -0,0 +1,108 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '@/api/category'
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: '', code: '', sortOrder: 0, description: '',
})
async function loadData() {
try {
const res = await getCategoryList()
tableData.value = res.data || []
parentOptions.value = (res.data || []).map((c) => ({ id: c.id, name: c.name, parentId: c.parentId }))
} catch { /* */ }
}
function handleAdd(parentId = 0) {
dialogTitle.value = '新增分类'
Object.assign(form, { id: null, parentId, name: '', code: '', sortOrder: 0, description: '' })
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑分类'
Object.assign(form, { ...row, parentId: row.parentId || 0 })
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm('确定删除该分类吗?子分类也会被删除。', '确认删除', { type: 'warning' })
await deleteCategory(row.id)
ElMessage.success('删除成功')
loadData()
}
async function handleSubmit() {
const data = { ...form }
delete data.id
if (form.id) {
await updateCategory(form.id, data)
ElMessage.success('编辑成功')
} else {
await createCategory(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="'category:add'" :icon="Plus" @click="handleAdd(0)">+ 新增顶级分类</el-button>
</div>
<el-table :data="tableData" border stripe row-key="id" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" default-expand-all>
<el-table-column prop="name" label="分类名称" min-width="200" />
<el-table-column prop="code" label="分类编码" width="150" />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" v-permission="'category:edit'" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" v-permission="'category:add'" @click="handleAdd(row.id)">添加子分类</el-button>
<el-button size="small" type="danger" v-permission="'category:delete'" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" label-width="80px">
<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)" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="名称" required>
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="编码" required>
<el-input v-model="form.code" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" style="width:100%" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</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

@@ -0,0 +1,58 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getCustomerList, createCustomer, updateCustomer, deleteCustomer } from '@/api/customer'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
const query = reactive({ page: 1, size: 10, name: '' })
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增客户')
const form = reactive({ id: null, name: '', contactPerson: '', phone: '', email: '', address: '', type: '个人', remark: '' })
async function loadData() { try { const r = await getCustomerList(query); tableData.value = r.data?.records || []; total.value = r.data?.total || 0 } catch { /* */ } }
function handleAdd() { dialogTitle.value = '新增客户'; Object.keys(form).forEach(k => form[k] = typeof form[k] === 'number' ? null : ''); form.type = '个人'; form.id = null; dialogVisible.value = true }
function handleEdit(row) { dialogTitle.value = '编辑客户'; Object.assign(form, row); dialogVisible.value = true }
async function handleDelete(row) { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await deleteCustomer(row.id); ElMessage.success('删除成功'); loadData() }
async function handleSubmit() { const d = { ...form }; delete d.id; if (form.id) { await updateCustomer(form.id, d); ElMessage.success('编辑成功') } else { await createCustomer(d); ElMessage.success('新增成功') }; dialogVisible.value = false; loadData() }
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.name" placeholder="客户名称" clearable />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="query.name='';loadData()">重置</el-button>
</div>
<div class="tool-bar">
<el-button type="primary" v-permission="'customer:add'" @click="handleAdd">+ 新增客户</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="name" label="客户名称" min-width="180" />
<el-table-column prop="contactPerson" label="联系人" width="100" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="type" label="类型" width="100" />
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }"><div class="table-actions"><el-button size="small" v-permission="'customer:edit'" @click="handleEdit(row)">编辑</el-button><el-button size="small" type="danger" v-permission="'customer:delete'" @click="handleDelete(row)">删除</el-button></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>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.contactPerson" /></el-form-item>
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="类型"><el-select v-model="form.type" style="width:100%"><el-option label="个人" value="个人" /><el-option label="医院" value="医院" /><el-option label="药店" value="药店" /><el-option label="诊所" value="诊所" /><el-option label="批发商" value="批发商" /></el-select></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" /></el-form-item>
</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

@@ -0,0 +1,120 @@
<script setup>
import { ref, onMounted } from 'vue'
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'
const statistics = ref({ medicineCount: 0, stockBatchCount: 0, pendingStockIn: 0, expiryWarningCount: 0 })
const expiryList = ref([])
const recentInList = ref([])
const recentOutList = ref([])
const statCards = [
{ key: 'medicineCount', label: '药品总数', color: '#409eff', icon: Goods },
{ key: 'stockBatchCount', label: '库存批次', color: '#67c23a', icon: Box },
{ key: 'pendingStockIn', label: '待审入库', color: '#e6a23c', icon: DocumentChecked },
{ key: 'expiryWarningCount', label: '效期预警', color: '#f56c6c', icon: Warning },
]
function expiryColor(date) {
const days = Math.ceil((new Date(date) - new Date()) / (1000 * 60 * 60 * 24))
if (days < 30) return 'danger'
if (days < 90) return 'warning'
return ''
}
onMounted(async () => {
try {
const [statRes, warnRes, inRes, outRes] = await Promise.all([
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 { /* 后端未启动,静默处理 */ }
})
</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 }">
<el-icon><component :is="card.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">{{ statistics[card.key] || 0 }}</div>
</div>
</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-table-column prop="quantity" label="库存" width="80" />
<el-table-column prop="expiryDate" label="有效期至" width="120">
<template #default="{ row }">
<el-tag :type="expiryColor(row.expiryDate || row.expiry_date)" size="small">
{{ row.expiryDate || row.expiry_date }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 最近记录 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 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 ? '待审核' : '已取消' }}
</el-tag>
</div>
</template>
<el-empty v-else description="暂无数据" />
</el-card>
<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 ? '待审核' : '已取消' }}
</el-tag>
</div>
</template>
<el-empty v-else description="暂无数据" />
</el-card>
</div>
</div>
</template>
<style lang="scss" scoped>
.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;
}
}
</style>

View File

@@ -0,0 +1,110 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { User, Lock } from '@element-plus/icons-vue'
const router = useRouter()
const userStore = useUserStore()
const loginFormRef = ref(null)
const loading = ref(false)
const loginForm = reactive({
username: 'admin',
password: 'admin123',
remember: false,
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await loginFormRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await userStore.login({
username: loginForm.username,
password: loginForm.password,
})
router.push('/dashboard')
} catch {
// 错误已在拦截器中处理
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<h2 class="login-title">药品管理系统</h2>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="rules"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
show-password
placeholder="密码"
:prefix-icon="Lock"
/>
</el-form-item>
<el-form-item>
<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"
>
{{ loading ? '登录中...' : '登 录' }}
</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%, #051f96 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: 32px;
color: #303133;
font-size: 24px;
}
</style>

View File

@@ -0,0 +1,254 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getMedicineList, createMedicine, updateMedicine, deleteMedicine } from '@/api/medicine'
import { getCategoryTree } from '@/api/category'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
const query = reactive({ page: 1, size: 10, name: '', categoryId: null })
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增药品')
const formRef = ref(null)
const categoryOptions = ref([])
const form = reactive({
id: null, categoryId: null, name: '', genericName: '', specification: '',
manufacturer: '', approvalNumber: '', barcode: '', dosageForm: '',
unit: '盒', retailPrice: 0, wholesalePrice: 0, storageCondition: '',
shelfLife: 24, warningStock: 10, description: '',
})
const rules = {
categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }],
name: [{ required: true, message: '请输入药品名称', trigger: 'blur' }],
specification: [{ required: true, message: '请输入规格', trigger: 'blur' }],
manufacturer: [{ required: true, message: '请输入生产厂家', trigger: 'blur' }],
approvalNumber: [{ required: true, message: '请输入批准文号', trigger: 'blur' }],
}
async function loadData() {
try {
const res = await getMedicineList(query)
tableData.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* 后端未启动 */ }
}
function resetSearch() {
query.name = ''
query.categoryId = null
query.page = 1
loadData()
}
function handleAdd() {
dialogTitle.value = '新增药品'
Object.assign(form, { id: null, categoryId: null, name: '', genericName: '', specification: '',
manufacturer: '', approvalNumber: '', barcode: '', dosageForm: '', unit: '盒',
retailPrice: 0, wholesalePrice: 0, storageCondition: '', shelfLife: 24, warningStock: 10, description: '' })
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑药品'
Object.assign(form, row)
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm('确定删除该药品吗?', '确认删除', { type: 'warning' })
await deleteMedicine(row.id)
ElMessage.success('删除成功')
loadData()
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const data = { ...form }
delete data.id
if (form.id) {
await updateMedicine(form.id, data)
ElMessage.success('编辑成功')
} else {
await createMedicine(data)
ElMessage.success('新增成功')
}
dialogVisible.value = false
loadData()
}
function handleSizeChange(size) {
query.size = size
query.page = 1
loadData()
}
onMounted(async () => {
try { categoryOptions.value = (await getCategoryTree()).data || [] } catch { /* */ }
loadData()
})
</script>
<template>
<div class="page-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="query.name" placeholder="药品名称" clearable @keyup.enter="loadData" />
<el-cascader
v-model="query.categoryId"
:options="categoryOptions"
:props="{ value: 'id', label: 'name', checkStrictly: true, emitPath: false }"
placeholder="药品分类"
clearable
/>
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
</div>
<!-- 工具栏 -->
<div class="tool-bar">
<el-button type="primary" v-permission="'medicine:add'" @click="handleAdd">+ 新增药品</el-button>
</div>
<!-- 表格 -->
<el-table :data="tableData" border stripe v-loading="false">
<el-table-column prop="name" label="药品名称" min-width="160" />
<el-table-column prop="specification" label="规格" width="120" />
<el-table-column prop="manufacturer" label="生产厂家" min-width="160" />
<el-table-column prop="approvalNumber" label="批准文号" width="140" show-overflow-tooltip />
<el-table-column prop="dosageForm" label="剂型" width="80" />
<el-table-column prop="retailPrice" label="零售价" width="90">
<template #default="{ row }">¥{{ row.retailPrice }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" v-permission="'medicine:edit'" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" v-permission="'medicine:delete'" @click="handleDelete(row)">删除</el-button>
</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"
@size-change="handleSizeChange"
@current-change="loadData"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="650px" @closed="formRef?.resetFields()">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" class="dialog-form">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="药品名称" prop="name">
<el-input v-model="form.name" placeholder="通用名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="英文名称" prop="genericName">
<el-input v-model="form.genericName" placeholder="化学通用名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="药品分类" prop="categoryId">
<el-cascader
v-model="form.categoryId"
:options="categoryOptions"
:props="{ value: 'id', label: 'name', checkStrictly: true, emitPath: false }"
placeholder="选择分类"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格" prop="specification">
<el-input v-model="form.specification" placeholder="如 0.25g×24粒" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="生产厂家" prop="manufacturer">
<el-input v-model="form.manufacturer" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="批准文号" prop="approvalNumber">
<el-input v-model="form.approvalNumber" placeholder="国药准字XXXX" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="条形码">
<el-input v-model="form.barcode" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="剂型">
<el-select v-model="form.dosageForm" placeholder="选择剂型">
<el-option label="片剂" value="片剂" />
<el-option label="胶囊剂" value="胶囊剂" />
<el-option label="注射液" value="注射液" />
<el-option label="口服液" value="口服液" />
<el-option label="颗粒剂" value="颗粒剂" />
<el-option label="乳膏剂" value="乳膏剂" />
<el-option label="丸剂" value="丸剂" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="单位">
<el-select v-model="form.unit">
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="支" value="支" />
<el-option label="袋" value="袋" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="零售价">
<el-input-number v-model="form.retailPrice" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="批发价">
<el-input-number v-model="form.wholesalePrice" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="储存条件">
<el-input v-model="form.storageCondition" placeholder="常温/阴凉/冷藏" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="有效期(月)">
<el-input-number v-model="form.shelfLife" :min="1" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="预警阈值">
<el-input-number v-model="form.warningStock" :min="0" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.description" type="textarea" :rows="2" />
</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

@@ -0,0 +1,67 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getRoleList, createRole, updateRole, deleteRole, assignMenus } from '@/api/role'
import { getMenusApi } from '@/api/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
const tableData = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增角色')
const form = reactive({ id: null, name: '', code: '', description: '', status: 1 })
const menuVisible = ref(false)
const menuForm = reactive({ roleId: null, menuIds: [] })
const menuTree = ref([])
const menuCheckKeys = ref([])
async function loadData() { try { const r = await getRoleList(); tableData.value = r.data?.records || r.data || [] } catch { /* */ } }
function handleAdd() { dialogTitle.value = '新增角色'; Object.keys(form).forEach(k => form[k] = k === 'status' ? 1 : ''); form.id = null; dialogVisible.value = true }
function handleEdit(row) { dialogTitle.value = '编辑角色'; Object.assign(form, row); dialogVisible.value = true }
async function handleDelete(row) { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await deleteRole(row.id); ElMessage.success('删除成功'); loadData() }
async function handleSubmit() { const d = { ...form }; delete d.id; if (form.id) { await updateRole(form.id, d); ElMessage.success('编辑成功') } else { await createRole(d); ElMessage.success('新增成功') }; dialogVisible.value = false; loadData() }
async function handleMenu(row) { menuForm.roleId = row.id; const menus = await getMenusApi(); menuTree.value = buildTree(menus.data || menus || []); menuCheckKeys.value = collectMenuIds(row.menuIds || []); menuVisible.value = true }
function buildTree(list, parentId = 0) { return list.filter(m => m.parentId === parentId).map(m => ({ ...m, children: buildTree(list, m.id) })) }
function collectMenuIds(ids) { return ids }
async function saveMenus() {
const ids = menuCheckKeys.value
await assignMenus(menuForm.roleId, { menuIds: ids })
ElMessage.success('权限分配成功'); menuVisible.value = false; loadData()
}
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="tool-bar">
<el-button type="primary" v-permission="'role:add'" @click="handleAdd">+ 新增角色</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="name" label="角色名称" width="140" />
<el-table-column prop="code" label="角色编码" width="140" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80" align="center"><template #default="{row}"><el-tag :type="row.status===1?'success':'danger'" size="small">{{row.status===1?'启用':'停用'}}</el-tag></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="'role:assign-menu'" @click="handleMenu(row)">权限</el-button><el-button size="small" v-permission="'role:edit'" @click="handleEdit(row)">编辑</el-button><el-button size="small" type="danger" v-permission="'role:delete'" @click="handleDelete(row)">删除</el-button></div></template>
</el-table-column>
</el-table>
<!-- 编辑角色弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="450px">
<el-form :model="form" label-width="70px">
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="编码" required><el-input v-model="form.code" /></el-form-item>
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="handleSubmit">确定</el-button></template>
</el-dialog>
<!-- 权限分配弹窗 -->
<el-dialog v-model="menuVisible" title="分配权限" width="500px">
<el-tree ref="menuTreeRef" :data="menuTree" show-checkbox node-key="id" :props="{label:'name',children:'children'}" :default-checked-keys="menuCheckKeys" @check="(_,{checkedKeys})=>menuCheckKeys=checkedKeys" />
<template #footer><el-button @click="menuVisible=false">取消</el-button><el-button type="primary" @click="saveMenus">保存</el-button></template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getStockList } from '@/api/stock'
import { Search, Refresh } from '@element-plus/icons-vue'
const query = reactive({ page: 1, size: 10, medicineName: '', batchNo: '' })
const tableData = ref([])
const total = ref(0)
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 'success'
}
function daysLeft(date) {
if (!date) return ''
return Math.ceil((new Date(date) - new Date()) / (1000 * 60 * 60 * 24))
}
async function loadData() { try { const r = await getStockList(query); tableData.value = r.data?.records || []; total.value = r.data?.total || 0 } catch { /* */ } }
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.medicineName" placeholder="药品名称" clearable />
<el-input v-model="query.batchNo" placeholder="批号" clearable />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="query.medicineName='';query.batchNo='';loadData()">重置</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="medicineName" label="药品名称" min-width="160" />
<el-table-column prop="batchNo" label="批号" width="120" />
<el-table-column prop="quantity" label="库存数量" width="100" align="center" />
<el-table-column prop="costPrice" label="成本价" width="100"><template #default="{row}">¥{{row.costPrice}}</template></el-table-column>
<el-table-column prop="expiryDate" label="有效期至" width="120">
<template #default="{row}">
<el-tag :type="expiryColor(row.expiryDate)" size="small">{{ row.expiryDate }} <span v-if="daysLeft(row.expiryDate) < 90">({{daysLeft(row.expiryDate)}})</span></el-tag>
</template>
</el-table-column>
<el-table-column prop="warehouseName" label="仓库" width="120" />
<el-table-column prop="productionDate" label="生产日期" width="120" />
</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>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAllSuppliers } from '@/api/supplier'
import { getMedicineList } from '@/api/medicine'
import { createStockIn } from '@/api/stockIn'
import { ElMessage } from 'element-plus'
const router = useRouter()
const supplierOptions = ref([])
const medicineOptions = ref([])
const form = reactive({ supplierId: null, warehouseId: 1, inDate: new Date().toISOString().slice(0, 10), remark: '' })
const details = ref([])
function addDetail() {
details.value.push({ medicineId: null, batchNo: '', quantity: 1, costPrice: 0, productionDate: '', expiryDate: '' })
}
function removeDetail(index) { details.value.splice(index, 1) }
function detailTotal(d) { return (d.quantity || 0) * (d.costPrice || 0) }
const totalAmount = ref(0)
function updateTotal() { totalAmount.value = details.value.reduce((s, d) => s + detailTotal(d), 0) }
async function loadOptions() {
try {
const [sRes, mRes] = await Promise.all([getAllSuppliers(), getMedicineList({ page: 1, size: 999 })])
supplierOptions.value = sRes.data || []
medicineOptions.value = mRes.data?.records || []
} catch { /* */ }
}
async function handleSubmit() {
if (!form.supplierId) { ElMessage.warning('请选择供应商'); return }
if (!details.value.length) { ElMessage.warning('请添加药品明细'); return }
updateTotal()
await createStockIn({ ...form, details: details.value, totalAmount: totalAmount.value })
ElMessage.success('入库单已创建,等待审核')
router.push('/stock-in/list')
}
onMounted(loadOptions)
</script>
<template>
<div class="page-container">
<h3 style="margin-bottom:16px">新建入库单</h3>
<el-form :model="form" label-width="90px" style="max-width:700px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="供应商" required><el-select v-model="form.supplierId" placeholder="选择供应商" filterable style="width:100%"><el-option v-for="s in supplierOptions" :key="s.id" :label="s.name" :value="s.id" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="入库日期" required><el-date-picker v-model="form.inDate" type="date" placeholder="选择日期" style="width:100%" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" /></el-form-item></el-col>
</el-row>
</el-form>
<!-- 药品明细 -->
<div style="margin:20px 0"><el-button type="primary" @click="addDetail">+ 添加药品</el-button><span style="margin-left:12px;color:#909399">总计: ¥{{ totalAmount }}</span></div>
<el-table :data="details" border>
<el-table-column label="药品" min-width="200">
<template #default="{row}"><el-select v-model="row.medicineId" placeholder="选择药品" filterable style="width:100%"><el-option v-for="m in medicineOptions" :key="m.id" :label="`${m.name} (${m.specification})`" :value="m.id" /></el-select></template>
</el-table-column>
<el-table-column label="批号" width="140"><template #default="{row}"><el-input v-model="row.batchNo" size="small" /></template></el-table-column>
<el-table-column label="数量" width="100"><template #default="{row}"><el-input-number v-model="row.quantity" :min="1" size="small" style="width:100%" @change="updateTotal" /></template></el-table-column>
<el-table-column label="进价" width="110"><template #default="{row}"><el-input-number v-model="row.costPrice" :min="0" :precision="2" size="small" style="width:100%" @change="updateTotal" /></template></el-table-column>
<el-table-column label="小计" width="110"><template #default="{row}">¥{{detailTotal(row)}}</template></el-table-column>
<el-table-column label="生产日期" width="140"><template #default="{row}"><el-date-picker v-model="row.productionDate" type="date" size="small" /></template></el-table-column>
<el-table-column label="有效期至" width="140"><template #default="{row}"><el-date-picker v-model="row.expiryDate" type="date" size="small" /></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button size="small" type="danger" @click="removeDetail($index)">移除</el-button></template></el-table-column>
</el-table>
<div style="margin-top:20px">
<el-button @click="router.back()">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交入库单</el-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,57 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getStockInList, auditStockIn } from '@/api/stockIn'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
const query = reactive({ page: 1, size: 10 })
const tableData = ref([])
const total = ref(0)
function statusTag(status) {
return { 0: 'warning', 1: 'success', 2: 'info' }[status] || 'info'
}
function statusText(status) {
return { 0: '待审核', 1: '已入库', 2: '已取消' }[status] || '未知'
}
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() }
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.recordNo" placeholder="入库单号" clearable style="width:180px" />
<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>
</div>
<div class="tool-bar">
<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-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="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>
</el-table-column>
<el-table-column label="操作" width="200" 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>
</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>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAllCustomers } from '@/api/customer'
import { getStockList } from '@/api/stock'
import { createStockOut } from '@/api/stockOut'
import { ElMessage } from 'element-plus'
const router = useRouter()
const customerOptions = ref([])
const stockOptions = ref([])
const form = reactive({ customerId: null, warehouseId: 1, outDate: new Date().toISOString().slice(0, 10), outType: 1, remark: '' })
const details = ref([])
function addDetail() {
details.value.push({ medicineId: null, batchNo: '', quantity: 1, unitPrice: 0 })
}
function removeDetail(i) { details.value.splice(i, 1) }
function detailTotal(d) { return (d.quantity || 0) * (d.unitPrice || 0) }
const totalAmount = ref(0)
function updateTotal() { totalAmount.value = details.value.reduce((s, d) => s + detailTotal(d), 0) }
async function loadOptions() {
try {
const [cRes, sRes] = await Promise.all([getAllCustomers(), getStockList({ page: 1, size: 999 })])
customerOptions.value = cRes.data || []
stockOptions.value = sRes.data?.records || []
} catch { /* */ }
}
function onMedicineSelected(index, stockId) {
const stock = stockOptions.value.find(s => s.id === stockId)
if (stock) {
details.value[index].medicineId = stock.medicineId
details.value[index].batchNo = stock.batchNo
details.value[index].unitPrice = stock.costPrice || 0
details.value[index]._maxQty = stock.quantity
}
}
async function handleSubmit() {
if (!form.customerId) { ElMessage.warning('请选择客户'); return }
if (!details.value.length) { ElMessage.warning('请添加药品明细'); return }
updateTotal()
await createStockOut({ ...form, details: details.value, totalAmount: totalAmount.value })
ElMessage.success('出库单已创建,等待审核')
router.push('/stock-out/list')
}
onMounted(loadOptions)
</script>
<template>
<div class="page-container">
<h3 style="margin-bottom:16px">新建出库单</h3>
<el-form :model="form" label-width="90px" style="max-width:700px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" placeholder="选择客户" filterable style="width:100%"><el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="出库类型"><el-select v-model="form.outType" style="width:100%"><el-option label="销售出库" :value="1" /><el-option label="领用出库" :value="2" /><el-option label="报损出库" :value="3" /><el-option label="退货出库" :value="4" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="出库日期" required><el-date-picker v-model="form.outDate" type="date" style="width:100%" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" /></el-form-item></el-col>
</el-row>
</el-form>
<div style="margin:20px 0"><el-button type="primary" @click="addDetail">+ 添加药品</el-button><span style="margin-left:12px;color:#909399">总计: ¥{{ totalAmount }}</span></div>
<el-table :data="details" border>
<el-table-column label="库存批次" min-width="220">
<template #default="{row, $index}">
<el-select :model-value="row._stockId" placeholder="选择药品批次" filterable style="width:100%" @change="onMedicineSelected($index, $event)">
<el-option v-for="s in stockOptions" :key="s.id" :label="`${s.medicineName} - ${s.batchNo} (库存${s.quantity})`" :value="s.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="批号" width="120"><template #default="{row}">{{row.batchNo}}</template></el-table-column>
<el-table-column label="数量" width="100"><template #default="{row}"><el-input-number v-model="row.quantity" :min="1" :max="row._maxQty||9999" size="small" style="width:100%" @change="updateTotal" /></template></el-table-column>
<el-table-column label="单价" width="110"><template #default="{row}"><el-input-number v-model="row.unitPrice" :min="0" :precision="2" size="small" style="width:100%" @change="updateTotal" /></template></el-table-column>
<el-table-column label="小计" width="110"><template #default="{row}">¥{{detailTotal(row)}}</template></el-table-column>
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button size="small" type="danger" @click="removeDetail($index)">移除</el-button></template></el-table-column>
</el-table>
<div style="margin-top:20px">
<el-button @click="router.back()">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交出库单</el-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getStockOutList, auditStockOut } from '@/api/stockOut'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
const query = reactive({ page: 1, size: 10 })
const tableData = ref([])
const total = ref(0)
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] || '未知' }
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() }
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.recordNo" placeholder="出库单号" clearable style="width:180px" />
<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>
</div>
<div class="tool-bar">
<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-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>
</el-table-column>
<el-table-column label="操作" width="200" 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>
</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>
</template>

View File

@@ -0,0 +1,78 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getSupplierList, createSupplier, updateSupplier, deleteSupplier } from '@/api/supplier'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
const query = reactive({ page: 1, size: 10, name: '', phone: '' })
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增供应商')
const formRef = ref(null)
const form = reactive({ id: null, name: '', contactPerson: '', phone: '', email: '', address: '', bankAccount: '', bankName: '', taxId: '', licenseNumber: '', status: 1, remark: '' })
async function loadData() {
try { const res = await getSupplierList(query); tableData.value = res.data?.records || []; total.value = res.data?.total || 0 } catch { /* */ }
}
function handleAdd() { dialogTitle.value = '新增供应商'; Object.keys(form).forEach(k => form[k] = k === 'status' ? 1 : null); form.id = null; dialogVisible.value = true }
function handleEdit(row) { dialogTitle.value = '编辑供应商'; Object.assign(form, row); dialogVisible.value = true }
async function handleDelete(row) { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await deleteSupplier(row.id); ElMessage.success('删除成功'); loadData() }
async function handleSubmit() { const data = { ...form }; delete data.id; if (form.id) { await updateSupplier(form.id, data); ElMessage.success('编辑成功') } else { await createSupplier(data); ElMessage.success('新增成功') }; dialogVisible.value = false; loadData() }
async function toggleStatus(row) { await updateSupplier(row.id, { status: row.status }); ElMessage.success('状态已更新') }
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.name" placeholder="供应商名称" clearable @keyup.enter="loadData" />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="query.name = ''; loadData()">重置</el-button>
</div>
<div class="tool-bar">
<el-button type="primary" v-permission="'supplier:add'" @click="handleAdd">+ 新增供应商</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="name" label="供应商名称" min-width="180" />
<el-table-column prop="contactPerson" label="联系人" width="100" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="email" label="邮箱" width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="toggleStatus(row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" v-permission="'supplier:edit'" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" v-permission="'supplier:delete'" @click="handleDelete(row)">删除</el-button>
</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>
<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="名称" 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.contactPerson" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="银行账号"><el-input v-model="form.bankAccount" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="开户银行"><el-input v-model="form.bankName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="税号"><el-input v-model="form.taxId" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="许可证号"><el-input v-model="form.licenseNumber" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="地址"><el-input v-model="form.address" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" /></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

@@ -0,0 +1,72 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getUserList, createUser, updateUser, deleteUser, assignRoles } from '@/api/user'
import { getAllRoles } from '@/api/role'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
const query = reactive({ page: 1, size: 10, username: '' })
const tableData = ref([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增用户')
const form = reactive({ id: null, username: '', password: '', realName: '', phone: '', email: '', status: 1 })
const roleVisible = ref(false)
const roleForm = reactive({ userId: null, roleIds: [] })
const allRoles = ref([])
async function loadData() { try { const r = await getUserList(query); tableData.value = r.data?.records || []; total.value = r.data?.total || 0 } catch { /* */ } }
function handleAdd() { dialogTitle.value = '新增用户'; Object.keys(form).forEach(k => form[k] = k === 'status' ? 1 : ''); form.id = null; dialogVisible.value = true }
function handleEdit(row) { dialogTitle.value = '编辑用户'; Object.assign(form, row); form.password = ''; dialogVisible.value = true }
async function handleDelete(row) { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await deleteUser(row.id); ElMessage.success('删除成功'); loadData() }
async function handleSubmit() { const d = { ...form }; delete d.id; if (!d.password) delete d.password; if (form.id) { await updateUser(form.id, d); ElMessage.success('编辑成功') } else { await createUser(d); ElMessage.success('新增成功') }; dialogVisible.value = false; loadData() }
async function handleRole(row) { roleForm.userId = row.id; roleForm.roleIds = row.roleIds || []; roleVisible.value = true }
async function saveRoles() { await assignRoles(roleForm.userId, { roleIds: roleForm.roleIds }); ElMessage.success('角色分配成功'); roleVisible.value = false; loadData() }
onMounted(async () => { try { allRoles.value = (await getAllRoles()).data || [] } catch { /* */ }; loadData() })
</script>
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.username" placeholder="用户名" clearable />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="query.username='';loadData()">重置</el-button>
</div>
<div class="tool-bar">
<el-button type="primary" v-permission="'user:add'" @click="handleAdd">+ 新增用户</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="realName" label="姓名" width="100" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="email" label="邮箱" width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80" align="center"><template #default="{row}"><el-tag :type="row.status===1?'success':'danger'" size="small">{{row.status===1?'启用':'停用'}}</el-tag></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="'user:edit'" @click="handleEdit(row)">编辑</el-button><el-button size="small" v-permission="'user:assign-role'" @click="handleRole(row)">角色</el-button><el-button size="small" type="danger" v-permission="'user:delete'" @click="handleDelete(row)">删除</el-button></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>
<!-- 用户弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form :model="form" label-width="80px">
<el-form-item label="用户名" required><el-input v-model="form.username" /></el-form-item>
<el-form-item label="密码" :required="!form.id"><el-input v-model="form.password" type="password" show-password :placeholder="form.id?'留空则不修改':''" /></el-form-item>
<el-form-item label="姓名"><el-input v-model="form.realName" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="handleSubmit">确定</el-button></template>
</el-dialog>
<!-- 角色分配弹窗 -->
<el-dialog v-model="roleVisible" title="分配角色" width="450px">
<el-checkbox-group v-model="roleForm.roleIds">
<el-checkbox v-for="r in allRoles" :key="r.id" :label="r.id" :value="r.id">{{ r.name }}</el-checkbox>
</el-checkbox-group>
<template #footer><el-button @click="roleVisible=false">取消</el-button><el-button type="primary" @click="saveRoles">保存</el-button></template>
</el-dialog>
</div>
</template>

View File

@@ -1,7 +1,22 @@
import { defineConfig } 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,
},
},
},
})