完善前端项目结构和依赖配置
- 更新 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:
36
frontend/package-lock.json
generated
36
frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
17
frontend/src/api/auth.js
Normal 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)
|
||||
}
|
||||
21
frontend/src/api/category.js
Normal file
21
frontend/src/api/category.js
Normal 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}`)
|
||||
}
|
||||
21
frontend/src/api/customer.js
Normal file
21
frontend/src/api/customer.js
Normal 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}`)
|
||||
}
|
||||
9
frontend/src/api/dashboard.js
Normal file
9
frontend/src/api/dashboard.js
Normal 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')
|
||||
}
|
||||
21
frontend/src/api/medicine.js
Normal file
21
frontend/src/api/medicine.js
Normal 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
25
frontend/src/api/role.js
Normal 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
17
frontend/src/api/stock.js
Normal 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)
|
||||
}
|
||||
17
frontend/src/api/stockIn.js
Normal file
17
frontend/src/api/stockIn.js
Normal 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)
|
||||
}
|
||||
17
frontend/src/api/stockOut.js
Normal file
17
frontend/src/api/stockOut.js
Normal 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)
|
||||
}
|
||||
21
frontend/src/api/supplier.js
Normal file
21
frontend/src/api/supplier.js
Normal 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
25
frontend/src/api/user.js
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
10
frontend/src/directives/permission.js
Normal file
10
frontend/src/directives/permission.js
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
202
frontend/src/layout/MainLayout.vue
Normal file
202
frontend/src/layout/MainLayout.vue
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
109
frontend/src/router/index.js
Normal file
109
frontend/src/router/index.js
Normal 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
|
||||
12
frontend/src/stores/app.js
Normal file
12
frontend/src/stores/app.js
Normal 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 }
|
||||
})
|
||||
96
frontend/src/stores/user.js
Normal file
96
frontend/src/stores/user.js
Normal 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 }
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
96
frontend/src/styles/global.scss
Normal file
96
frontend/src/styles/global.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
frontend/src/utils/request.js
Normal file
61
frontend/src/utils/request.js
Normal 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
|
||||
108
frontend/src/views/category/CategoryList.vue
Normal file
108
frontend/src/views/category/CategoryList.vue
Normal 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>
|
||||
58
frontend/src/views/customer/CustomerList.vue
Normal file
58
frontend/src/views/customer/CustomerList.vue
Normal 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>
|
||||
120
frontend/src/views/dashboard/DashboardView.vue
Normal file
120
frontend/src/views/dashboard/DashboardView.vue
Normal 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>
|
||||
110
frontend/src/views/login/LoginView.vue
Normal file
110
frontend/src/views/login/LoginView.vue
Normal 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>
|
||||
254
frontend/src/views/medicine/MedicineList.vue
Normal file
254
frontend/src/views/medicine/MedicineList.vue
Normal 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>
|
||||
67
frontend/src/views/role/RoleList.vue
Normal file
67
frontend/src/views/role/RoleList.vue
Normal 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>
|
||||
50
frontend/src/views/stock/StockList.vue
Normal file
50
frontend/src/views/stock/StockList.vue
Normal 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>
|
||||
74
frontend/src/views/stockIn/StockInForm.vue
Normal file
74
frontend/src/views/stockIn/StockInForm.vue
Normal 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>
|
||||
57
frontend/src/views/stockIn/StockInList.vue
Normal file
57
frontend/src/views/stockIn/StockInList.vue
Normal 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>
|
||||
86
frontend/src/views/stockOut/StockOutForm.vue
Normal file
86
frontend/src/views/stockOut/StockOutForm.vue
Normal 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>
|
||||
55
frontend/src/views/stockOut/StockOutList.vue
Normal file
55
frontend/src/views/stockOut/StockOutList.vue
Normal 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>
|
||||
78
frontend/src/views/supplier/SupplierList.vue
Normal file
78
frontend/src/views/supplier/SupplierList.vue
Normal 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>
|
||||
72
frontend/src/views/user/UserList.vue
Normal file
72
frontend/src/views/user/UserList.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user