From 15da4fe4922a65fd4d47d287353e638973d2d7ec Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 6 Jul 2026 12:07:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=89=8D=E7=AB=AF=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BB=93=E6=9E=84=E5=92=8C=E4=BE=9D=E8=B5=96=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20-=20=E6=9B=B4=E6=96=B0=20package.json=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?=E5=8C=85=E6=8B=AC=20element-plus=E3=80=81axios=E3=80=81pinia?= =?UTF-8?q?=E3=80=81vue-router=20=E7=AD=89=20-=20=E5=88=A0=E9=99=A4=20Hell?= =?UTF-8?q?oWorld=20=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=9B=BF=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=20router-view=20=E4=BB=A5=E6=94=AF=E6=8C=81=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20-=20=E6=B7=BB=E5=8A=A0=20auth=E3=80=81cate?= =?UTF-8?q?gory=E3=80=81customer=E3=80=81dashboard=E3=80=81medicine?= =?UTF-8?q?=E3=80=81role=E3=80=81stock=E3=80=81stockIn=E3=80=81stockOut?= =?UTF-8?q?=E3=80=81=20=20=20supplier=E3=80=81user=20=E7=AD=89=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=20API=20=E6=8E=A5=E5=8F=A3=E6=96=87=E4=BB=B6=20-=20?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E6=9D=83=E9=99=90=E6=8C=87=E4=BB=A4=E3=80=81?= =?UTF-8?q?=E4=B8=BB=E5=B8=83=E5=B1=80=E7=BB=84=E4=BB=B6=E5=92=8C=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E6=A0=B7=E5=BC=8F=20-=20=E9=9B=86=E6=88=90=20Element?= =?UTF-8?q?=20Plus=20UI=20=E6=A1=86=E6=9E=B6=E5=B9=B6=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E7=BB=84=E4=BB=B6=20-=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E5=92=8C?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=20Store?= =?UTF-8?q?=20-=20=E9=85=8D=E7=BD=AE=20Vue=20Router=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E8=B7=AF=E7=94=B1=E7=94=9F=E6=88=90=20-=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=97=A7=E7=9A=84=20CSS=20=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E6=94=B9=E7=94=A8=20SCSS=20?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E6=A0=B7=E5=BC=8F=20```?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 36 ++- frontend/package.json | 14 +- frontend/src/App.vue | 3 +- frontend/src/api/auth.js | 17 + frontend/src/api/category.js | 21 ++ frontend/src/api/customer.js | 21 ++ frontend/src/api/dashboard.js | 9 + frontend/src/api/medicine.js | 21 ++ frontend/src/api/role.js | 25 ++ frontend/src/api/stock.js | 17 + frontend/src/api/stockIn.js | 17 + frontend/src/api/stockOut.js | 17 + frontend/src/api/supplier.js | 21 ++ frontend/src/api/user.js | 25 ++ frontend/src/components/HelloWorld.vue | 95 ------ frontend/src/directives/permission.js | 10 + frontend/src/layout/MainLayout.vue | 202 ++++++++++++ frontend/src/main.js | 21 +- frontend/src/router/index.js | 109 +++++++ frontend/src/stores/app.js | 12 + frontend/src/stores/user.js | 96 ++++++ frontend/src/style.css | 296 ------------------ frontend/src/styles/global.scss | 96 ++++++ frontend/src/utils/request.js | 61 ++++ frontend/src/views/category/CategoryList.vue | 108 +++++++ frontend/src/views/customer/CustomerList.vue | 58 ++++ .../src/views/dashboard/DashboardView.vue | 120 +++++++ frontend/src/views/login/LoginView.vue | 110 +++++++ frontend/src/views/medicine/MedicineList.vue | 254 +++++++++++++++ frontend/src/views/role/RoleList.vue | 67 ++++ frontend/src/views/stock/StockList.vue | 50 +++ frontend/src/views/stockIn/StockInForm.vue | 74 +++++ frontend/src/views/stockIn/StockInList.vue | 57 ++++ frontend/src/views/stockOut/StockOutForm.vue | 86 +++++ frontend/src/views/stockOut/StockOutList.vue | 55 ++++ frontend/src/views/supplier/SupplierList.vue | 78 +++++ frontend/src/views/user/UserList.vue | 72 +++++ frontend/vite.config.js | 15 + 38 files changed, 2057 insertions(+), 409 deletions(-) create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/category.js create mode 100644 frontend/src/api/customer.js create mode 100644 frontend/src/api/dashboard.js create mode 100644 frontend/src/api/medicine.js create mode 100644 frontend/src/api/role.js create mode 100644 frontend/src/api/stock.js create mode 100644 frontend/src/api/stockIn.js create mode 100644 frontend/src/api/stockOut.js create mode 100644 frontend/src/api/supplier.js create mode 100644 frontend/src/api/user.js delete mode 100644 frontend/src/components/HelloWorld.vue create mode 100644 frontend/src/directives/permission.js create mode 100644 frontend/src/layout/MainLayout.vue create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/app.js create mode 100644 frontend/src/stores/user.js delete mode 100644 frontend/src/style.css create mode 100644 frontend/src/styles/global.scss create mode 100644 frontend/src/utils/request.js create mode 100644 frontend/src/views/category/CategoryList.vue create mode 100644 frontend/src/views/customer/CustomerList.vue create mode 100644 frontend/src/views/dashboard/DashboardView.vue create mode 100644 frontend/src/views/login/LoginView.vue create mode 100644 frontend/src/views/medicine/MedicineList.vue create mode 100644 frontend/src/views/role/RoleList.vue create mode 100644 frontend/src/views/stock/StockList.vue create mode 100644 frontend/src/views/stockIn/StockInForm.vue create mode 100644 frontend/src/views/stockIn/StockInList.vue create mode 100644 frontend/src/views/stockOut/StockOutForm.vue create mode 100644 frontend/src/views/stockOut/StockOutList.vue create mode 100644 frontend/src/views/supplier/SupplierList.vue create mode 100644 frontend/src/views/user/UserList.vue diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d20b273..0f79bb9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index ebb6c2b..8356e35 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 62ebe1b..64505e7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,6 @@ diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..d4b00be --- /dev/null +++ b/frontend/src/api/auth.js @@ -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) +} diff --git a/frontend/src/api/category.js b/frontend/src/api/category.js new file mode 100644 index 0000000..6f69424 --- /dev/null +++ b/frontend/src/api/category.js @@ -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}`) +} diff --git a/frontend/src/api/customer.js b/frontend/src/api/customer.js new file mode 100644 index 0000000..65e704d --- /dev/null +++ b/frontend/src/api/customer.js @@ -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}`) +} diff --git a/frontend/src/api/dashboard.js b/frontend/src/api/dashboard.js new file mode 100644 index 0000000..5974456 --- /dev/null +++ b/frontend/src/api/dashboard.js @@ -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') +} diff --git a/frontend/src/api/medicine.js b/frontend/src/api/medicine.js new file mode 100644 index 0000000..48ce2e8 --- /dev/null +++ b/frontend/src/api/medicine.js @@ -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}`) +} diff --git a/frontend/src/api/role.js b/frontend/src/api/role.js new file mode 100644 index 0000000..515bb19 --- /dev/null +++ b/frontend/src/api/role.js @@ -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) +} diff --git a/frontend/src/api/stock.js b/frontend/src/api/stock.js new file mode 100644 index 0000000..05d22c3 --- /dev/null +++ b/frontend/src/api/stock.js @@ -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) +} diff --git a/frontend/src/api/stockIn.js b/frontend/src/api/stockIn.js new file mode 100644 index 0000000..b3c27a3 --- /dev/null +++ b/frontend/src/api/stockIn.js @@ -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) +} diff --git a/frontend/src/api/stockOut.js b/frontend/src/api/stockOut.js new file mode 100644 index 0000000..384ef89 --- /dev/null +++ b/frontend/src/api/stockOut.js @@ -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) +} diff --git a/frontend/src/api/supplier.js b/frontend/src/api/supplier.js new file mode 100644 index 0000000..6390895 --- /dev/null +++ b/frontend/src/api/supplier.js @@ -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}`) +} diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js new file mode 100644 index 0000000..ba7fde1 --- /dev/null +++ b/frontend/src/api/user.js @@ -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) +} diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue deleted file mode 100644 index f91553d..0000000 --- a/frontend/src/components/HelloWorld.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/frontend/src/directives/permission.js b/frontend/src/directives/permission.js new file mode 100644 index 0000000..226369a --- /dev/null +++ b/frontend/src/directives/permission.js @@ -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) + } + }, +} diff --git a/frontend/src/layout/MainLayout.vue b/frontend/src/layout/MainLayout.vue new file mode 100644 index 0000000..8dec66b --- /dev/null +++ b/frontend/src/layout/MainLayout.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js index 2425c0f..e4eba25 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..1e99f7c --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/stores/app.js b/frontend/src/stores/app.js new file mode 100644 index 0000000..0791bd9 --- /dev/null +++ b/frontend/src/stores/app.js @@ -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 } +}) diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js new file mode 100644 index 0000000..cd59c9b --- /dev/null +++ b/frontend/src/stores/user.js @@ -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 } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css deleted file mode 100644 index 527d4fb..0000000 --- a/frontend/src/style.css +++ /dev/null @@ -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); - } -} diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss new file mode 100644 index 0000000..0355d62 --- /dev/null +++ b/frontend/src/styles/global.scss @@ -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; + } + } +} diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..7785520 --- /dev/null +++ b/frontend/src/utils/request.js @@ -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 diff --git a/frontend/src/views/category/CategoryList.vue b/frontend/src/views/category/CategoryList.vue new file mode 100644 index 0000000..1b2f2e0 --- /dev/null +++ b/frontend/src/views/category/CategoryList.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/src/views/customer/CustomerList.vue b/frontend/src/views/customer/CustomerList.vue new file mode 100644 index 0000000..7e71f3f --- /dev/null +++ b/frontend/src/views/customer/CustomerList.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue new file mode 100644 index 0000000..7da9ef0 --- /dev/null +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/views/login/LoginView.vue b/frontend/src/views/login/LoginView.vue new file mode 100644 index 0000000..c90e492 --- /dev/null +++ b/frontend/src/views/login/LoginView.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/frontend/src/views/medicine/MedicineList.vue b/frontend/src/views/medicine/MedicineList.vue new file mode 100644 index 0000000..3e10d5b --- /dev/null +++ b/frontend/src/views/medicine/MedicineList.vue @@ -0,0 +1,254 @@ + + + diff --git a/frontend/src/views/role/RoleList.vue b/frontend/src/views/role/RoleList.vue new file mode 100644 index 0000000..baf1012 --- /dev/null +++ b/frontend/src/views/role/RoleList.vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/src/views/stock/StockList.vue b/frontend/src/views/stock/StockList.vue new file mode 100644 index 0000000..9fbbadc --- /dev/null +++ b/frontend/src/views/stock/StockList.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/views/stockIn/StockInForm.vue b/frontend/src/views/stockIn/StockInForm.vue new file mode 100644 index 0000000..d882036 --- /dev/null +++ b/frontend/src/views/stockIn/StockInForm.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/src/views/stockIn/StockInList.vue b/frontend/src/views/stockIn/StockInList.vue new file mode 100644 index 0000000..28afdcd --- /dev/null +++ b/frontend/src/views/stockIn/StockInList.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/views/stockOut/StockOutForm.vue b/frontend/src/views/stockOut/StockOutForm.vue new file mode 100644 index 0000000..b6e1669 --- /dev/null +++ b/frontend/src/views/stockOut/StockOutForm.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/views/stockOut/StockOutList.vue b/frontend/src/views/stockOut/StockOutList.vue new file mode 100644 index 0000000..7e73f0e --- /dev/null +++ b/frontend/src/views/stockOut/StockOutList.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/views/supplier/SupplierList.vue b/frontend/src/views/supplier/SupplierList.vue new file mode 100644 index 0000000..deb901f --- /dev/null +++ b/frontend/src/views/supplier/SupplierList.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/views/user/UserList.vue b/frontend/src/views/user/UserList.vue new file mode 100644 index 0000000..cb672f1 --- /dev/null +++ b/frontend/src/views/user/UserList.vue @@ -0,0 +1,72 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index bbcf80c..1445a6c 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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, + }, + }, + }, })