diff --git a/admin-snack/.env.development b/admin-snack/.env.development new file mode 100644 index 0000000..b5da26a --- /dev/null +++ b/admin-snack/.env.development @@ -0,0 +1,4 @@ +# 开发环境 +VITE_APP_TITLE=零食商城管理后台 +VITE_API_BASE_URL=http://localhost:8080 +VITE_APP_ENV=development diff --git a/admin-snack/.env.production b/admin-snack/.env.production new file mode 100644 index 0000000..7894340 --- /dev/null +++ b/admin-snack/.env.production @@ -0,0 +1,3 @@ +VITE_APP_TITLE=零食商城管理后台 +VITE_API_BASE_URL= +VITE_APP_ENV=production diff --git a/admin-snack/.env.test b/admin-snack/.env.test new file mode 100644 index 0000000..56afa92 --- /dev/null +++ b/admin-snack/.env.test @@ -0,0 +1,3 @@ +VITE_APP_TITLE=零食商城管理后台 +VITE_API_BASE_URL=http://localhost:8080 +VITE_APP_ENV=test diff --git a/admin-snack/.gitignore b/admin-snack/.gitignore new file mode 100644 index 0000000..c0b3202 --- /dev/null +++ b/admin-snack/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.DS_Store +*.log +.vscode +.idea diff --git a/admin-snack/.prettierrc.cjs.json b/admin-snack/.prettierrc.cjs.json new file mode 100644 index 0000000..5d08536 --- /dev/null +++ b/admin-snack/.prettierrc.cjs.json @@ -0,0 +1,16 @@ +{ + "importOrder": [ + "^vue", + "^vue-router", + "^pinia", + "^axios", + "^element-plus", + "^echarts", + "^dayjs", + "^[a-zA-Z]", + "^@/(.*)$", + "^[./]" + ], + "importOrderSeparation": true, + "importOrderParsePlugins": ["typescript", "vue"] +} diff --git a/admin-snack/.prettierrc.json b/admin-snack/.prettierrc.json new file mode 100644 index 0000000..4d07294 --- /dev/null +++ b/admin-snack/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf", + "vueIndentScriptAndStyle": false +} diff --git a/admin-snack/README.md b/admin-snack/README.md new file mode 100644 index 0000000..1882caf --- /dev/null +++ b/admin-snack/README.md @@ -0,0 +1,146 @@ +# Admin Snack — 零食商城管理后台 + +基于 **Vue 3 + Vite 6 + TypeScript 5 + Element Plus** 的现代化电商管理后台。 + +## 技术栈 + +| 类别 | 技术 | +|------|------| +| 框架 | Vue 3.5 (Composition API + ` + + diff --git a/admin-snack/package.json b/admin-snack/package.json new file mode 100644 index 0000000..b9a10fd --- /dev/null +++ b/admin-snack/package.json @@ -0,0 +1,59 @@ +{ + "name": "admin-snack", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "零食商城管理后台", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "build:test": "vue-tsc --noEmit && vite build --mode test", + "build:prod": "vue-tsc --noEmit && vite build --mode production", + "preview": "vite preview", + "type-check": "vue-tsc --noEmit", + "lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx --fix", + "format": "prettier --write \"src/**/*.{vue,ts,js,tsx,jsx,css,scss,md,json}\"" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0", + "pinia": "^2.3.0", + "pinia-plugin-persistedstate": "^4.2.0", + "axios": "^1.7.9", + "element-plus": "^2.9.1", + "@element-plus/icons-vue": "^2.3.1", + "echarts": "^5.5.1", + "vue-echarts": "^7.0.3", + "dayjs": "^1.11.13", + "nprogress": "^0.2.0", + "js-cookie": "^3.0.5", + "lodash-es": "^4.17.21", + "mitt": "^3.0.1", + "@vueuse/core": "^12.0.0", + "wangeditor": "^4.7.15" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/lodash-es": "^4.17.12", + "@types/js-cookie": "^3.0.6", + "@types/nprogress": "^0.2.3", + "@vitejs/plugin-vue": "^5.2.1", + "@vitejs/plugin-vue-jsx": "^4.1.1", + "vite": "^6.0.5", + "unplugin-auto-import": "^19.0.0", + "unplugin-vue-components": "^28.0.0", + "vue-tsc": "^2.2.0", + "typescript": "^5.7.2", + "@typescript-eslint/parser": "^8.18.0", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@vue/eslint-config-typescript": "^14.1.4", + "eslint": "^9.17.0", + "eslint-plugin-vue": "^9.32.0", + "prettier": "^3.4.2", + "sass": "^1.83.0", + "unocss": "^0.65.4" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/admin-snack/public/favicon.svg b/admin-snack/public/favicon.svg new file mode 100644 index 0000000..b2095f9 --- /dev/null +++ b/admin-snack/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + S + diff --git a/admin-snack/src/App.vue b/admin-snack/src/App.vue new file mode 100644 index 0000000..0f2bbe9 --- /dev/null +++ b/admin-snack/src/App.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/admin-snack/src/api/auth.ts b/admin-snack/src/api/auth.ts new file mode 100644 index 0000000..dd7a9d3 --- /dev/null +++ b/admin-snack/src/api/auth.ts @@ -0,0 +1,35 @@ +import { request } from '@/utils/request' +import type { LoginParams, LoginResult, AdminInfo } from '@/types/auth' + +/** 登录 */ +export function loginApi(params: LoginParams) { + return request({ + url: '/api/admin/login', + method: 'POST', + data: params + }) +} + +/** 登出 */ +export function logoutApi() { + return request({ + url: '/api/admin/logout', + method: 'POST' + }) +} + +/** 获取当前管理员信息 */ +export function getAdminInfoApi() { + return request({ + url: '/api/admin/info', + method: 'GET' + }) +} + +/** 获取图形验证码 */ +export function getCaptchaApi() { + return request<{ key: string; image: string }>({ + url: '/api/admin/captcha', + method: 'GET' + }) +} diff --git a/admin-snack/src/api/chat.ts b/admin-snack/src/api/chat.ts new file mode 100644 index 0000000..2a26075 --- /dev/null +++ b/admin-snack/src/api/chat.ts @@ -0,0 +1,46 @@ +import { request } from '@/utils/request' +import type { PageResult, PageParams } from '@/types/api' + +export interface ChatSession { + id: number + sessionNo: string + userId: number + username: string + adminId: number | null + status: 0 | 1 | 2 + lastMessage: string + unreadCount: number + updateTime: string +} + +export interface ChatMessage { + id: number + sessionId: number + senderType: 0 | 1 + type: 'text' | 'image' | 'system' + content: string + createTime: string +} + +export function getChatSessionPageApi(params: PageParams) { + return request>({ + url: '/api/admin/chat/session/page', + method: 'GET', + params + }) +} + +export function getChatMessagesApi(sessionId: number) { + return request({ + url: `/api/admin/chat/session/${sessionId}/messages`, + method: 'GET' + }) +} + +export function sendChatMessageApi(sessionId: number, data: { type: string; content: string }) { + return request({ + url: `/api/admin/chat/session/${sessionId}/message`, + method: 'POST', + data + }) +} diff --git a/admin-snack/src/api/coupon.ts b/admin-snack/src/api/coupon.ts new file mode 100644 index 0000000..764471b --- /dev/null +++ b/admin-snack/src/api/coupon.ts @@ -0,0 +1,39 @@ +import { request } from '@/utils/request' +import type { PageResult, PageParams } from '@/types/api' + +export interface CouponItem { + id: number + name: string + type: 0 | 1 | 2 + amount: number + minAmount: number + total: number + remain: number + status: 0 | 1 | 2 + startTime: string + endTime: string +} + +export function getCouponPageApi(params: PageParams) { + return request>({ + url: '/api/admin/coupon/page', + method: 'GET', + params + }) +} + +export function createCouponApi(data: Partial) { + return request({ + url: '/api/admin/coupon', + method: 'POST', + data + }) +} + +export function updateCouponStatusApi(id: number, status: 0 | 1) { + return request({ + url: `/api/admin/coupon/${id}/status`, + method: 'PUT', + data: { status } + }) +} diff --git a/admin-snack/src/api/dashboard.ts b/admin-snack/src/api/dashboard.ts new file mode 100644 index 0000000..0122147 --- /dev/null +++ b/admin-snack/src/api/dashboard.ts @@ -0,0 +1,34 @@ +import { request } from '@/utils/request' + +export function getDashboardStatsApi() { + return request<{ + todayOrders: number + todaySales: number + totalUsers: number + totalProducts: number + }>({ + url: '/api/admin/dashboard/stats', + method: 'GET' + }) +} + +export function getSalesTrendApi(days: number) { + return request<{ date: string; amount: number }[]>({ + url: `/api/admin/dashboard/sales-trend?days=${days}`, + method: 'GET' + }) +} + +export function getOrderStatusDistributionApi() { + return request<{ name: string; value: number }[]>({ + url: '/api/admin/dashboard/order-status', + method: 'GET' + }) +} + +export function getHotProductsApi() { + return request<{ name: string; sales: number }[]>({ + url: '/api/admin/dashboard/hot-products', + method: 'GET' + }) +} diff --git a/admin-snack/src/api/file.ts b/admin-snack/src/api/file.ts new file mode 100644 index 0000000..f953e80 --- /dev/null +++ b/admin-snack/src/api/file.ts @@ -0,0 +1,16 @@ +import { request } from '@/utils/request' + +/** + * 通用文件上传 + */ +export function uploadFileApi(file: File, folder = 'common') { + const formData = new FormData() + formData.append('file', file) + formData.append('folder', folder) + return request<{ url: string; name: string; size: number }>({ + url: '/api/admin/file/upload', + method: 'POST', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' } + }) +} diff --git a/admin-snack/src/api/notice.ts b/admin-snack/src/api/notice.ts new file mode 100644 index 0000000..f1e7360 --- /dev/null +++ b/admin-snack/src/api/notice.ts @@ -0,0 +1,37 @@ +import { request } from '@/utils/request' +import type { PageResult, PageParams } from '@/types/api' + +export interface NoticeItem { + id: number + title: string + type: 0 | 1 | 2 + isTop: 0 | 1 + status: 0 | 1 + startTime: string + endTime: string + createTime: string +} + +export function getNoticePageApi(params: PageParams) { + return request>({ + url: '/api/admin/notice/page', + method: 'GET', + params + }) +} + +export function createNoticeApi(data: Partial & { content: string }) { + return request({ + url: '/api/admin/notice', + method: 'POST', + data + }) +} + +export function updateNoticeStatusApi(id: number, status: 0 | 1) { + return request({ + url: `/api/admin/notice/${id}/status`, + method: 'PUT', + data: { status } + }) +} diff --git a/admin-snack/src/api/order.ts b/admin-snack/src/api/order.ts new file mode 100644 index 0000000..0a2c657 --- /dev/null +++ b/admin-snack/src/api/order.ts @@ -0,0 +1,38 @@ +import { request } from '@/utils/request' +import type { PageParams, PageResult } from '@/types/api' + +export interface OrderItem { + id: number + orderNo: string + userId: number + username: string + totalAmount: number + payAmount: number + status: 0 | 1 | 2 | 3 | 4 + receiverName: string + receiverPhone: string + createTime: string +} + +export function getOrderPageApi(params: PageParams & { status?: number; keyword?: string }) { + return request>({ + url: '/api/admin/order/page', + method: 'GET', + params + }) +} + +export function deliverOrderApi(id: number, data: { company: string; trackingNo: string }) { + return request({ + url: `/api/admin/order/${id}/deliver`, + method: 'PUT', + data + }) +} + +export function cancelOrderApi(id: number) { + return request({ + url: `/api/admin/order/${id}/cancel`, + method: 'PUT' + }) +} diff --git a/admin-snack/src/api/product.ts b/admin-snack/src/api/product.ts new file mode 100644 index 0000000..9169f87 --- /dev/null +++ b/admin-snack/src/api/product.ts @@ -0,0 +1,46 @@ +import { request } from '@/utils/request' +import type { PageParams, PageResult } from '@/types/api' + +export interface CategoryItem { + id: number + name: string + parentId: number + level: number + sort: number + icon: string +} + +export interface ProductItem { + id: number + name: string + categoryId: number + mainImage: string + price: number + stock: number + sales: number + status: 0 | 1 + createTime: string +} + +export function getCategoryListApi() { + return request({ + url: '/api/admin/category/list', + method: 'GET' + }) +} + +export function getProductPageApi(params: PageParams) { + return request>({ + url: '/api/admin/product/page', + method: 'GET', + params + }) +} + +export function updateProductStatusApi(id: number, status: 0 | 1) { + return request({ + url: `/api/admin/product/${id}/status`, + method: 'PUT', + data: { status } + }) +} diff --git a/admin-snack/src/api/seckill.ts b/admin-snack/src/api/seckill.ts new file mode 100644 index 0000000..4e7dcb9 --- /dev/null +++ b/admin-snack/src/api/seckill.ts @@ -0,0 +1,34 @@ +import { request } from '@/utils/request' +import type { PageResult, PageParams } from '@/types/api' + +export interface SeckillActivity { + id: number + name: string + startTime: string + endTime: string + status: 0 | 1 | 2 + createTime: string +} + +export function getSeckillPageApi(params: PageParams) { + return request>({ + url: '/api/admin/seckill/page', + method: 'GET', + params + }) +} + +export function createSeckillApi(data: Partial) { + return request({ + url: '/api/admin/seckill', + method: 'POST', + data + }) +} + +export function endSeckillApi(id: number) { + return request({ + url: `/api/admin/seckill/${id}/end`, + method: 'PUT' + }) +} diff --git a/admin-snack/src/api/user.ts b/admin-snack/src/api/user.ts new file mode 100644 index 0000000..ab7421a --- /dev/null +++ b/admin-snack/src/api/user.ts @@ -0,0 +1,27 @@ +import { request } from '@/utils/request' +import type { PageParams, PageResult } from '@/types/api' + +export interface UserItem { + id: number + username: string + phone: string + avatar: string + status: 0 | 1 + createTime: string +} + +export function getUserPageApi(params: PageParams & { keyword?: string; status?: number }) { + return request>({ + url: '/api/admin/user/page', + method: 'GET', + params + }) +} + +export function updateUserStatusApi(id: number, status: 0 | 1) { + return request({ + url: `/api/admin/user/${id}/status`, + method: 'PUT', + data: { status } + }) +} diff --git a/admin-snack/src/layouts/BasicLayout.vue b/admin-snack/src/layouts/BasicLayout.vue new file mode 100644 index 0000000..913c8c6 --- /dev/null +++ b/admin-snack/src/layouts/BasicLayout.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/admin-snack/src/layouts/components/NavBreadcrumb.vue b/admin-snack/src/layouts/components/NavBreadcrumb.vue new file mode 100644 index 0000000..c3ecd5e --- /dev/null +++ b/admin-snack/src/layouts/components/NavBreadcrumb.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/admin-snack/src/layouts/components/SideMenu.vue b/admin-snack/src/layouts/components/SideMenu.vue new file mode 100644 index 0000000..1afab0d --- /dev/null +++ b/admin-snack/src/layouts/components/SideMenu.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/admin-snack/src/main.ts b/admin-snack/src/main.ts new file mode 100644 index 0000000..d23fb42 --- /dev/null +++ b/admin-snack/src/main.ts @@ -0,0 +1,28 @@ +import { createApp } from 'vue' +import App from './App.vue' + +// 状态管理 +import pinia from './stores' + +// 路由 +import router from './router' + +// 全局样式 +import 'element-plus/theme-chalk/dark/css-vars.css' +import 'element-plus/dist/index.css' +import './styles/index.scss' +import 'virtual:uno.css' +import 'nprogress/nprogress.css' + +// 业务样式 +import './assets/styles/main.scss' + +// 权限控制 +import './permission' + +const app = createApp(App) + +app.use(pinia) +app.use(router) + +app.mount('#app') diff --git a/admin-snack/src/permission.ts b/admin-snack/src/permission.ts new file mode 100644 index 0000000..32ff57b --- /dev/null +++ b/admin-snack/src/permission.ts @@ -0,0 +1,51 @@ +import router from '@/router' +import NProgress from 'nprogress' +import { useUserStore } from '@/stores/modules/user' + +NProgress.configure({ showSpinner: false }) + +const WHITE_LIST = ['/login', '/404'] + +router.beforeEach(async (to, _from, next) => { + NProgress.start() + document.title = (to.meta.title as string) || '零食商城管理后台' + + const userStore = useUserStore() + + if (WHITE_LIST.includes(to.path)) { + if (to.path === '/login' && userStore.isLoggedIn) { + next('/') + } else { + next() + } + return + } + + if (!userStore.isLoggedIn) { + next(`/login?redirect=${to.path}`) + NProgress.done() + return + } + + // 首次登录加载管理员信息 + if (!userStore.adminInfo) { + try { + await userStore.fetchAdminInfo() + // 动态挂载业务路由 + const asyncRoutes = (await import('@/router/async-routes')).default + asyncRoutes.forEach((route) => router.addRoute(route)) + next({ ...to, replace: true }) + } catch (err) { + userStore.clearAuth() + next(`/login?redirect=${to.path}`) + NProgress.done() + } + return + } + + next() +}) + +router.afterEach(() => { + NProgress.done() +}) diff --git a/admin-snack/src/router/async-routes.ts b/admin-snack/src/router/async-routes.ts new file mode 100644 index 0000000..df7df59 --- /dev/null +++ b/admin-snack/src/router/async-routes.ts @@ -0,0 +1,120 @@ +import type { RouteRecordRaw } from 'vue-router' + +/** + * 业务路由(按权限动态挂载) + * 与 router/index.ts 中的 asyncRoutes 内容保持一致 + */ +const asyncRoutes: RouteRecordRaw[] = [ + { + path: '/', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '仪表盘', icon: 'Odometer', keepAlive: true } + } + ] + }, + { + path: '/product', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/product/list', + meta: { title: '商品管理', icon: 'Goods' }, + children: [ + { + path: 'list', + name: 'ProductList', + component: () => import('@/views/product/list.vue'), + meta: { title: '商品列表', icon: 'List' } + }, + { + path: 'category', + name: 'ProductCategory', + component: () => import('@/views/product/category.vue'), + meta: { title: '商品分类', icon: 'Menu' } + } + ] + }, + { + path: '/order', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/order/list', + children: [ + { + path: 'list', + name: 'OrderList', + component: () => import('@/views/order/list.vue'), + meta: { title: '订单管理', icon: 'Document' } + } + ] + }, + { + path: '/user', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/user/list', + children: [ + { + path: 'list', + name: 'UserList', + component: () => import('@/views/user/list.vue'), + meta: { title: '用户管理', icon: 'User' } + } + ] + }, + { + path: '/marketing', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/marketing/coupon', + meta: { title: '营销管理', icon: 'Present' }, + children: [ + { + path: 'coupon', + name: 'CouponList', + component: () => import('@/views/coupon/list.vue'), + meta: { title: '优惠券', icon: 'Ticket' } + }, + { + path: 'seckill', + name: 'SeckillList', + component: () => import('@/views/seckill/list.vue'), + meta: { title: '限时抢购', icon: 'AlarmClock' } + } + ] + }, + { + path: '/notice', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/notice/list', + children: [ + { + path: 'list', + name: 'NoticeList', + component: () => import('@/views/notice/list.vue'), + meta: { title: '系统公告', icon: 'Bell' } + } + ] + }, + { + path: '/chat', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/chat/list', + children: [ + { + path: 'list', + name: 'ChatList', + component: () => import('@/views/chat/list.vue'), + meta: { title: '客服消息', icon: 'ChatDotRound' } + } + ] + }, + { + path: '/:pathMatch(.*)*', + redirect: '/404', + meta: { hidden: true } + } +] + +export default asyncRoutes diff --git a/admin-snack/src/router/index.ts b/admin-snack/src/router/index.ts new file mode 100644 index 0000000..a6e5df5 --- /dev/null +++ b/admin-snack/src/router/index.ts @@ -0,0 +1,149 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' + +/** + * 基础路由(无需登录) + */ +const baseRoutes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/login/index.vue'), + meta: { title: '登录', hidden: true } + }, + { + path: '/404', + name: 'NotFound', + component: () => import('@/views/error/404.vue'), + meta: { title: '404', hidden: true } + } +] + +/** + * 业务路由(需要登录) + * meta 配置说明: + * title - 菜单与面包屑显示名 + * icon - 菜单图标(Element Plus 图标组件名) + * hidden - 是否在菜单中隐藏 + * roles - 允许访问的角色 + * keepAlive - 是否缓存 + */ +export const asyncRoutes: RouteRecordRaw[] = [ + { + path: '/', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '仪表盘', icon: 'Odometer', keepAlive: true } + } + ] + }, + { + path: '/product', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/product/list', + meta: { title: '商品管理', icon: 'Goods' }, + children: [ + { + path: 'list', + name: 'ProductList', + component: () => import('@/views/product/list.vue'), + meta: { title: '商品列表', icon: 'List' } + }, + { + path: 'category', + name: 'ProductCategory', + component: () => import('@/views/product/category.vue'), + meta: { title: '商品分类', icon: 'Menu' } + } + ] + }, + { + path: '/order', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/order/list', + children: [ + { + path: 'list', + name: 'OrderList', + component: () => import('@/views/order/list.vue'), + meta: { title: '订单管理', icon: 'Document' } + } + ] + }, + { + path: '/user', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/user/list', + children: [ + { + path: 'list', + name: 'UserList', + component: () => import('@/views/user/list.vue'), + meta: { title: '用户管理', icon: 'User' } + } + ] + }, + { + path: '/marketing', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/marketing/coupon', + meta: { title: '营销管理', icon: 'Present' }, + children: [ + { + path: 'coupon', + name: 'CouponList', + component: () => import('@/views/coupon/list.vue'), + meta: { title: '优惠券', icon: 'Ticket' } + }, + { + path: 'seckill', + name: 'SeckillList', + component: () => import('@/views/seckill/list.vue'), + meta: { title: '限时抢购', icon: 'AlarmClock' } + } + ] + }, + { + path: '/notice', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/notice/list', + children: [ + { + path: 'list', + name: 'NoticeList', + component: () => import('@/views/notice/list.vue'), + meta: { title: '系统公告', icon: 'Bell' } + } + ] + }, + { + path: '/chat', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/chat/list', + children: [ + { + path: 'list', + name: 'ChatList', + component: () => import('@/views/chat/list.vue'), + meta: { title: '客服消息', icon: 'ChatDotRound' } + } + ] + }, + { + path: '/:pathMatch(.*)*', + redirect: '/404', + meta: { hidden: true } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes: baseRoutes, + scrollBehavior: () => ({ left: 0, top: 0 }) +}) + +export default router diff --git a/admin-snack/src/stores/index.ts b/admin-snack/src/stores/index.ts new file mode 100644 index 0000000..e952ed8 --- /dev/null +++ b/admin-snack/src/stores/index.ts @@ -0,0 +1,7 @@ +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +const pinia = createPinia() +pinia.use(piniaPluginPersistedstate) + +export default pinia diff --git a/admin-snack/src/stores/modules/app.ts b/admin-snack/src/stores/modules/app.ts new file mode 100644 index 0000000..4e7a267 --- /dev/null +++ b/admin-snack/src/stores/modules/app.ts @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +/** + * 应用全局状态:侧边栏折叠、主题、设备尺寸 + */ +export const useAppStore = defineStore( + 'app', + () => { + const sidebarCollapsed = ref(false) + const device = ref<'desktop' | 'mobile'>('desktop') + + function toggleSidebar() { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + function toggleDevice(d: 'desktop' | 'mobile') { + device.value = d + } + + return { sidebarCollapsed, device, toggleSidebar, toggleDevice } + }, + { + persist: { + key: 'snack-admin-app', + pick: ['sidebarCollapsed'] + } + } +) diff --git a/admin-snack/src/stores/modules/user.ts b/admin-snack/src/stores/modules/user.ts new file mode 100644 index 0000000..c27ba8f --- /dev/null +++ b/admin-snack/src/stores/modules/user.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { loginApi, logoutApi, getAdminInfoApi } from '@/api/auth' +import type { LoginParams, AdminInfo } from '@/types/auth' + +/** + * 用户 / 管理员信息 Store + */ +export const useUserStore = defineStore( + 'user', + () => { + const token = ref('') + const adminInfo = ref(null) + + const isLoggedIn = computed(() => !!token.value) + const username = computed(() => adminInfo.value?.username ?? '') + const avatar = computed(() => adminInfo.value?.avatar ?? '') + + async function login(params: LoginParams) { + const { data } = await loginApi(params) + token.value = data.token + return data + } + + async function fetchAdminInfo() { + const { data } = await getAdminInfoApi() + adminInfo.value = data + return data + } + + async function logout() { + try { + await logoutApi() + } finally { + clearAuth() + } + } + + function clearAuth() { + token.value = '' + adminInfo.value = null + } + + return { + token, + adminInfo, + isLoggedIn, + username, + avatar, + login, + logout, + fetchAdminInfo, + clearAuth + } + }, + { + persist: { + key: 'snack-admin-user', + pick: ['token', 'adminInfo'] + } + } +) diff --git a/admin-snack/src/styles/element.scss b/admin-snack/src/styles/element.scss new file mode 100644 index 0000000..245cc69 --- /dev/null +++ b/admin-snack/src/styles/element.scss @@ -0,0 +1,53 @@ +// Element Plus 主题覆盖 — 统一品牌色 + 圆角风格 +@use './variables.scss' as *; + +/* 主按钮 */ +.el-button--primary { + --el-button-bg-color: #{$brand-primary}; + --el-button-border-color: #{$brand-primary}; + --el-button-hover-bg-color: #{$brand-primary-light}; + --el-button-hover-border-color: #{$brand-primary-light}; + --el-button-active-bg-color: #{$brand-primary-dark}; + --el-button-active-border-color: #{$brand-primary-dark}; + border-radius: $radius-sm; +} + +/* 卡片 */ +.el-card { + border-radius: $radius-md !important; + border: 1px solid $color-border-light !important; + box-shadow: $shadow-sm !important; +} + +/* 菜单 */ +.el-menu { + border-right: none !important; +} + +/* 表格 */ +.el-table { + border-radius: $radius-sm; + th.el-table__cell { + background-color: $bg-page !important; + color: $color-text-regular; + font-weight: 600; + } +} + +/* 输入框 */ +.el-input__wrapper, +.el-textarea__inner, +.el-select__wrapper { + border-radius: $radius-sm !important; +} + +/* 分页 */ +.el-pagination { + justify-content: flex-end; + margin-top: 16px; +} + +/* Dialog */ +.el-dialog { + border-radius: $radius-md !important; +} diff --git a/admin-snack/src/styles/index.scss b/admin-snack/src/styles/index.scss new file mode 100644 index 0000000..cd89bd4 --- /dev/null +++ b/admin-snack/src/styles/index.scss @@ -0,0 +1,5 @@ +// 全局样式入口 +@use './variables.scss' as *; +@use './reset.scss'; +@use './element.scss'; +@use './transitions.scss'; diff --git a/admin-snack/src/styles/reset.scss b/admin-snack/src/styles/reset.scss new file mode 100644 index 0000000..09a6e5a --- /dev/null +++ b/admin-snack/src/styles/reset.scss @@ -0,0 +1,64 @@ +// 浏览器基础样式重置 +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +html, +body, +#app { + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', + 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, + sans-serif; + font-size: $font-size-md; + color: $color-text-primary; + background-color: $bg-page; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +button, +input, +textarea, +select { + font-family: inherit; + font-size: inherit; + color: inherit; + outline: none; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +// 滚动条 +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-thumb { + background: #c0c4cc; + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: #909399; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/admin-snack/src/styles/transitions.scss b/admin-snack/src/styles/transitions.scss new file mode 100644 index 0000000..9b71a15 --- /dev/null +++ b/admin-snack/src/styles/transitions.scss @@ -0,0 +1,40 @@ +// Vue Router / 路由切换过渡 +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +// 滑入 +.slide-up-enter-active, +.slide-up-leave-active { + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} +.slide-up-enter-from { + opacity: 0; + transform: translateY(10px); +} +.slide-up-leave-to { + opacity: 0; + transform: translateY(-10px); +} + +// 列表项依次进入 +.list-enter-active { + transition: all 0.3s ease; +} +.list-leave-active { + transition: all 0.3s ease; + position: absolute; +} +.list-enter-from { + opacity: 0; + transform: translateX(-20px); +} +.list-leave-to { + opacity: 0; + transform: translateX(20px); +} diff --git a/admin-snack/src/styles/variables.scss b/admin-snack/src/styles/variables.scss new file mode 100644 index 0000000..8c266e5 --- /dev/null +++ b/admin-snack/src/styles/variables.scss @@ -0,0 +1,52 @@ +// 全局 SCSS 变量(设计系统令牌) +// 品牌色(蓝色主调) +$brand-primary: #1E40AF; +$brand-primary-light: #3B82F6; +$brand-primary-dark: #1E3A8A; +$brand-secondary: #60A5FA; + +// 状态色 +$color-success: #10B981; +$color-warning: #F59E0B; +$color-danger: #EF4444; +$color-info: #6B7280; + +// 中性色 +$color-text-primary: #1F2937; +$color-text-regular: #4B5563; +$color-text-secondary: #6B7280; +$color-text-placeholder: #9CA3AF; + +$color-border: #E5E7EB; +$color-border-light: #F3F4F6; +$color-divider: #E5E7EB; + +// 背景 +$bg-page: #F8FAFC; +$bg-card: #FFFFFF; +$bg-hover: #F3F4F6; + +// 圆角 +$radius-sm: 6px; +$radius-md: 10px; +$radius-lg: 16px; +$radius-xl: 20px; + +// 阴影 +$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-md: 0 4px 12px 0 rgba(0, 0, 0, 0.06); +$shadow-lg: 0 12px 32px 0 rgba(0, 0, 0, 0.08); + +// 布局 +$header-height: 60px; +$sidebar-width: 220px; +$sidebar-collapsed-width: 64px; + +// 字号 +$font-size-xs: 12px; +$font-size-sm: 13px; +$font-size-md: 14px; +$font-size-lg: 16px; +$font-size-xl: 20px; +$font-size-xxl: 24px; +$font-size-display: 32px; diff --git a/admin-snack/src/types/api.ts b/admin-snack/src/types/api.ts new file mode 100644 index 0000000..0afdfce --- /dev/null +++ b/admin-snack/src/types/api.ts @@ -0,0 +1,28 @@ +/** + * 后端 Result 响应结构 + */ +export interface Result { + code: number + message: string + data: T +} + +/** + * 分页响应数据 + */ +export interface PageResult { + records: T[] + total: number + size: number + current: number + pages: number +} + +/** + * 分页请求参数 + */ +export interface PageParams { + current: number + size: number + [key: string]: unknown +} diff --git a/admin-snack/src/types/auth.ts b/admin-snack/src/types/auth.ts new file mode 100644 index 0000000..951cbd8 --- /dev/null +++ b/admin-snack/src/types/auth.ts @@ -0,0 +1,28 @@ +/** + * 登录参数 + */ +export interface LoginParams { + username: string + password: string + captchaKey?: string + captchaCode?: string +} + +/** + * 登录响应 + */ +export interface LoginResult { + token: string +} + +/** + * 管理员信息 + */ +export interface AdminInfo { + id: number + username: string + nickname: string + avatar: string + role: string + permissions: string[] +} diff --git a/admin-snack/src/types/shims.d.ts b/admin-snack/src/types/shims.d.ts new file mode 100644 index 0000000..5398e0b --- /dev/null +++ b/admin-snack/src/types/shims.d.ts @@ -0,0 +1,17 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + readonly VITE_APP_TITLE: string + readonly VITE_API_BASE_URL: string + readonly VITE_APP_ENV: 'development' | 'test' | 'production' +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/admin-snack/src/utils/bus.ts b/admin-snack/src/utils/bus.ts new file mode 100644 index 0000000..f124c10 --- /dev/null +++ b/admin-snack/src/utils/bus.ts @@ -0,0 +1,14 @@ +import mitt, { type Emitter } from 'mitt' + +/** + * 全局事件总线 + * 用法:emitter.emit('logout') / emitter.on('logout', handler) + */ +type Events = { + logout: void + refresh: string +} + +const emitter: Emitter = mitt() + +export default emitter diff --git a/admin-snack/src/utils/date.ts b/admin-snack/src/utils/date.ts new file mode 100644 index 0000000..927c043 --- /dev/null +++ b/admin-snack/src/utils/date.ts @@ -0,0 +1,23 @@ +import dayjs from 'dayjs' +import 'dayjs/locale/zh-cn' +import relativeTime from 'dayjs/plugin/relativeTime' + +dayjs.extend(relativeTime) +dayjs.locale('zh-cn') + +/** 格式化日期时间 */ +export function formatDate(date: string | Date | number, fmt = 'YYYY-MM-DD HH:mm:ss') { + if (!date) return '-' + return dayjs(date).format(fmt) +} + +/** 相对时间 */ +export function fromNow(date: string | Date | number) { + if (!date) return '-' + return dayjs(date).fromNow() +} + +/** 仅日期 */ +export function formatDateOnly(date: string | Date | number) { + return formatDate(date, 'YYYY-MM-DD') +} diff --git a/admin-snack/src/utils/dict.ts b/admin-snack/src/utils/dict.ts new file mode 100644 index 0000000..6607146 --- /dev/null +++ b/admin-snack/src/utils/dict.ts @@ -0,0 +1,29 @@ +/** 订单状态文本与颜色 */ +export const ORDER_STATUS_MAP = { + 0: { text: '待付款', color: '#F59E0B' }, + 1: { text: '待发货', color: '#3B82F6' }, + 2: { text: '待收货', color: '#1E40AF' }, + 3: { text: '已完成', color: '#10B981' }, + 4: { text: '已取消', color: '#9CA3AF' } +} as const + +/** 优惠券类型 */ +export const COUPON_TYPE_MAP = { + 0: '满减券', + 1: '折扣券', + 2: '无门槛券' +} as const + +/** 公告类型 */ +export const NOTICE_TYPE_MAP = { + 0: '普通公告', + 1: '重要公告', + 2: '活动公告' +} as const + +/** 抢购活动状态 */ +export const SECKILL_STATUS_MAP = { + 0: '未开始', + 1: '进行中', + 2: '已结束' +} as const diff --git a/admin-snack/src/utils/request.ts b/admin-snack/src/utils/request.ts new file mode 100644 index 0000000..113a4cb --- /dev/null +++ b/admin-snack/src/utils/request.ts @@ -0,0 +1,90 @@ +import axios, { + type AxiosInstance, + type AxiosRequestConfig, + type AxiosResponse, + type InternalAxiosRequestConfig +} from 'axios' +import { ElMessage } from 'element-plus' +import NProgress from 'nprogress' +import { useUserStore } from '@/stores/modules/user' + +NProgress.configure({ showSpinner: false }) + +/** + * 后端统一响应结构 + */ +export interface ApiResponse { + code: number + message: string + data: T +} + +const service: AxiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 15000, + withCredentials: false +}) + +// 请求拦截器 +service.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + NProgress.start() + const userStore = useUserStore() + if (userStore.token) { + config.headers.Authorization = `Bearer ${userStore.token}` + } + return config + }, + (error) => { + NProgress.done() + return Promise.reject(error) + } +) + +// 响应拦截器 +service.interceptors.response.use( + (response: AxiosResponse) => { + NProgress.done() + const { code, message, data } = response.data + + if (code === 200) { + return data as any + } + + ElMessage.error(message || '请求失败') + return Promise.reject(new Error(message || 'Error')) + }, + (error) => { + NProgress.done() + const status = error.response?.status + let message = error.message + + if (status === 401) { + message = '登录已过期,请重新登录' + const userStore = useUserStore() + userStore.clearAuth() + // 避免循环依赖,使用 import() 动态加载 + import('@/router').then(({ default: router }) => { + router.push('/login') + }) + } else if (status === 403) { + message = '无权限访问' + } else if (status === 404) { + message = '资源不存在' + } else if (status === 500) { + message = '服务器内部错误' + } + + ElMessage.error(message) + return Promise.reject(error) + } +) + +/** + * 统一请求方法封装 + */ +export function request(config: AxiosRequestConfig): Promise { + return service.request(config) +} + +export default service diff --git a/admin-snack/src/views/chat/list.vue b/admin-snack/src/views/chat/list.vue new file mode 100644 index 0000000..4ec4615 --- /dev/null +++ b/admin-snack/src/views/chat/list.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/admin-snack/src/views/coupon/list.vue b/admin-snack/src/views/coupon/list.vue new file mode 100644 index 0000000..f759b57 --- /dev/null +++ b/admin-snack/src/views/coupon/list.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/admin-snack/src/views/dashboard/index.vue b/admin-snack/src/views/dashboard/index.vue new file mode 100644 index 0000000..74fb234 --- /dev/null +++ b/admin-snack/src/views/dashboard/index.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/admin-snack/src/views/error/404.vue b/admin-snack/src/views/error/404.vue new file mode 100644 index 0000000..32398f2 --- /dev/null +++ b/admin-snack/src/views/error/404.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/admin-snack/src/views/login/index.vue b/admin-snack/src/views/login/index.vue new file mode 100644 index 0000000..7c4558f --- /dev/null +++ b/admin-snack/src/views/login/index.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/admin-snack/src/views/notice/list.vue b/admin-snack/src/views/notice/list.vue new file mode 100644 index 0000000..338226b --- /dev/null +++ b/admin-snack/src/views/notice/list.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/admin-snack/src/views/order/list.vue b/admin-snack/src/views/order/list.vue new file mode 100644 index 0000000..a85cbc7 --- /dev/null +++ b/admin-snack/src/views/order/list.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/admin-snack/src/views/product/category.vue b/admin-snack/src/views/product/category.vue new file mode 100644 index 0000000..6308e5c --- /dev/null +++ b/admin-snack/src/views/product/category.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/admin-snack/src/views/product/list.vue b/admin-snack/src/views/product/list.vue new file mode 100644 index 0000000..5e63cbd --- /dev/null +++ b/admin-snack/src/views/product/list.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/admin-snack/src/views/seckill/list.vue b/admin-snack/src/views/seckill/list.vue new file mode 100644 index 0000000..9566c97 --- /dev/null +++ b/admin-snack/src/views/seckill/list.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/admin-snack/src/views/user/list.vue b/admin-snack/src/views/user/list.vue new file mode 100644 index 0000000..06b30b8 --- /dev/null +++ b/admin-snack/src/views/user/list.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/admin-snack/tsconfig.json b/admin-snack/tsconfig.json new file mode 100644 index 0000000..5821056 --- /dev/null +++ b/admin-snack/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "element-plus/global"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue", + "vite.config.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/admin-snack/tsconfig.node.json b/admin-snack/tsconfig.node.json new file mode 100644 index 0000000..e8def99 --- /dev/null +++ b/admin-snack/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": true, + "types": ["unplugin-auto-import/types", "unplugin-vue-components/types"] + }, + "include": [ + "auto-imports.d.ts", + "components.d.ts" + ] +} diff --git a/admin-snack/uno.config.ts b/admin-snack/uno.config.ts new file mode 100644 index 0000000..de3ea0a --- /dev/null +++ b/admin-snack/uno.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, presetUno, presetAttributify, presetIcons } from 'unocss' + +/** + * UnoCSS 配置 — 与 Element Plus 协作 + * Element Plus 提供组件库,UnoCSS 提供工具类(仅作辅助) + */ +export default defineConfig({ + presets: [ + presetUno(), + presetAttributify(), + presetIcons({ + scale: 1.2, + cdn: 'https://cdn.jsdelivr.net/npm/@iconify-json/carbon-icons' + }) + ], + theme: { + colors: { + brand: { + 50: '#EFF6FF', + 100: '#DBEAFE', + 200: '#BFDBFE', + 300: '#93C5FD', + 400: '#60A5FA', + 500: '#3B82F6', + 600: '#1E40AF', + 700: '#1E3A8A', + 800: '#1E3A8A', + 900: '#172554' + } + } + }, + shortcuts: { + 'flex-center': 'flex items-center justify-center', + 'flex-between': 'flex items-center justify-between' + }, + safelist: ['i-carbon-sun', 'i-carbon-moon'] +}) diff --git a/admin-snack/vite.config.ts b/admin-snack/vite.config.ts new file mode 100644 index 0000000..8b76c57 --- /dev/null +++ b/admin-snack/vite.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import UnoCSS from 'unocss/vite' +import { fileURLToPath, URL } from 'node:url' + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd()) + + return { + base: './', + plugins: [ + vue(), + vueJsx(), + AutoImport({ + imports: ['vue', 'vue-router', 'pinia'], + resolvers: [ElementPlusResolver()], + dts: 'src/types/auto-imports.d.ts', + eslintrc: { enabled: true } + }), + Components({ + resolvers: [ElementPlusResolver()], + dts: 'src/types/components.d.ts' + }), + UnoCSS() + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + css: { + preprocessorOptions: { + scss: { + additionalData: `@use "@/styles/variables.scss" as *;` + } + } + }, + server: { + host: '0.0.0.0', + port: 5173, + open: true, + cors: true, + proxy: { + '/api': { + target: env.VITE_API_BASE_URL || 'http://localhost:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '/api') + }, + '/uploads': { + target: env.VITE_API_BASE_URL || 'http://localhost:8080', + changeOrigin: true + } + } + }, + build: { + target: 'es2020', + outDir: 'dist', + assetsDir: 'static', + sourcemap: false, + minify: 'esbuild', + chunkSizeWarningLimit: 1500, + rollupOptions: { + output: { + manualChunks: { + vue: ['vue', 'vue-router', 'pinia'], + element: ['element-plus', '@element-plus/icons-vue'], + echarts: ['echarts', 'vue-echarts'], + editor: ['wangeditor'] + } + } + } + } + } +})