Compare commits
3 Commits
3ea0678c25
...
3dd146f871
| Author | SHA1 | Date |
|---|---|---|
|
|
3dd146f871 | |
|
|
1701bdb800 | |
|
|
7f5e67c62b |
|
|
@ -0,0 +1,4 @@
|
|||
# 开发环境
|
||||
VITE_APP_TITLE=零食商城管理后台
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_APP_ENV=development
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
VITE_APP_TITLE=零食商城管理后台
|
||||
VITE_API_BASE_URL=
|
||||
VITE_APP_ENV=production
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
VITE_APP_TITLE=零食商城管理后台
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_APP_ENV=test
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"globals": {
|
||||
"Component": true,
|
||||
"ComponentPublicInstance": true,
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
"InjectionKey": true,
|
||||
"MaybeRef": true,
|
||||
"MaybeRefOrGetter": true,
|
||||
"PropType": true,
|
||||
"Ref": true,
|
||||
"Slot": true,
|
||||
"Slots": true,
|
||||
"VNode": true,
|
||||
"WritableComputedRef": true,
|
||||
"acceptHMRUpdate": true,
|
||||
"computed": true,
|
||||
"createApp": true,
|
||||
"createPinia": true,
|
||||
"customRef": true,
|
||||
"defineAsyncComponent": true,
|
||||
"defineComponent": true,
|
||||
"defineStore": true,
|
||||
"effectScope": true,
|
||||
"getActivePinia": true,
|
||||
"getCurrentInstance": true,
|
||||
"getCurrentScope": true,
|
||||
"h": true,
|
||||
"inject": true,
|
||||
"isProxy": true,
|
||||
"isReactive": true,
|
||||
"isReadonly": true,
|
||||
"isRef": true,
|
||||
"mapActions": true,
|
||||
"mapGetters": true,
|
||||
"mapState": true,
|
||||
"mapStores": true,
|
||||
"mapWritableState": true,
|
||||
"markRaw": true,
|
||||
"nextTick": true,
|
||||
"onActivated": true,
|
||||
"onBeforeMount": true,
|
||||
"onBeforeRouteLeave": true,
|
||||
"onBeforeRouteUpdate": true,
|
||||
"onBeforeUnmount": true,
|
||||
"onBeforeUpdate": true,
|
||||
"onDeactivated": true,
|
||||
"onErrorCaptured": true,
|
||||
"onMounted": true,
|
||||
"onRenderTracked": true,
|
||||
"onRenderTriggered": true,
|
||||
"onScopeDispose": true,
|
||||
"onServerPrefetch": true,
|
||||
"onUnmounted": true,
|
||||
"onUpdated": true,
|
||||
"onWatcherCleanup": true,
|
||||
"provide": true,
|
||||
"reactive": true,
|
||||
"readonly": true,
|
||||
"ref": true,
|
||||
"resolveComponent": true,
|
||||
"setActivePinia": true,
|
||||
"setMapStoreSuffix": true,
|
||||
"shallowReactive": true,
|
||||
"shallowReadonly": true,
|
||||
"shallowRef": true,
|
||||
"storeToRefs": true,
|
||||
"toRaw": true,
|
||||
"toRef": true,
|
||||
"toRefs": true,
|
||||
"toValue": true,
|
||||
"triggerRef": true,
|
||||
"unref": true,
|
||||
"useAttrs": true,
|
||||
"useCssModule": true,
|
||||
"useCssVars": true,
|
||||
"useId": true,
|
||||
"useLink": true,
|
||||
"useModel": true,
|
||||
"useRoute": true,
|
||||
"useRouter": true,
|
||||
"useSlots": true,
|
||||
"useTemplateRef": true,
|
||||
"watch": true,
|
||||
"watchEffect": true,
|
||||
"watchPostEffect": true,
|
||||
"watchSyncEffect": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"importOrder": [
|
||||
"^vue",
|
||||
"^vue-router",
|
||||
"^pinia",
|
||||
"^axios",
|
||||
"^element-plus",
|
||||
"^echarts",
|
||||
"^dayjs",
|
||||
"^[a-zA-Z]",
|
||||
"^@/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderParsePlugins": ["typescript", "vue"]
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"vueIndentScriptAndStyle": false
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# Admin Snack — 零食商城管理后台
|
||||
|
||||
基于 **Vue 3 + Vite 6 + TypeScript 5 + Element Plus** 的现代化电商管理后台。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
|------|------|
|
||||
| 框架 | Vue 3.5 (Composition API + `<script setup>`) |
|
||||
| 语言 | TypeScript 5 |
|
||||
| 构建 | Vite 6 |
|
||||
| 路由 | Vue Router 4 |
|
||||
| 状态 | Pinia 2 + 持久化 |
|
||||
| UI 库 | Element Plus 2.9 |
|
||||
| 工具类 | UnoCSS(辅助) |
|
||||
| 图表 | ECharts 5 + vue-echarts |
|
||||
| HTTP | Axios |
|
||||
| 工具 | dayjs、lodash-es、mitt、nprogress、VueUse |
|
||||
| 代码规范 | ESLint 9 + Prettier 3 |
|
||||
| Node 要求 | ≥ 20 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
npm install
|
||||
|
||||
# 2. 启动开发服务器
|
||||
npm run dev
|
||||
# 访问 http://localhost:5173
|
||||
|
||||
# 3. 生产构建
|
||||
npm run build:prod
|
||||
|
||||
# 4. 类型检查
|
||||
npm run type-check
|
||||
|
||||
# 5. 代码格式化
|
||||
npm run format
|
||||
```
|
||||
|
||||
## 默认账号
|
||||
|
||||
```
|
||||
用户名:admin
|
||||
密 码:123456
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
admin-snack/
|
||||
├── public/ # 不经 vite 处理的静态资源
|
||||
│ └── favicon.svg
|
||||
├── src/
|
||||
│ ├── api/ # 接口请求层(按业务模块划分)
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── dashboard.ts
|
||||
│ │ ├── product.ts
|
||||
│ │ ├── order.ts
|
||||
│ │ └── ...
|
||||
│ ├── assets/ # 静态资源
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ ├── common/
|
||||
│ │ └── business/
|
||||
│ ├── hooks/ # 组合式函数
|
||||
│ ├── layouts/ # 布局
|
||||
│ │ ├── BasicLayout.vue # 主框架(侧边栏+顶栏+内容区)
|
||||
│ │ └── components/
|
||||
│ │ ├── SideMenu.vue
|
||||
│ │ └── NavBreadcrumb.vue
|
||||
│ ├── permission.ts # 路由守卫
|
||||
│ ├── router/ # 路由
|
||||
│ │ ├── index.ts # 基础路由
|
||||
│ │ └── async-routes.ts # 业务路由(按权限动态挂载)
|
||||
│ ├── stores/ # Pinia
|
||||
│ │ ├── index.ts
|
||||
│ │ └── modules/
|
||||
│ │ ├── user.ts # 用户/管理员
|
||||
│ │ └── app.ts # 应用全局
|
||||
│ ├── styles/ # 全局样式
|
||||
│ │ ├── variables.scss # 设计系统令牌
|
||||
│ │ ├── reset.scss
|
||||
│ │ ├── element.scss # Element Plus 主题覆盖
|
||||
│ │ ├── transitions.scss
|
||||
│ │ └── index.scss
|
||||
│ ├── types/ # TS 类型
|
||||
│ │ ├── api.ts
|
||||
│ │ └── auth.ts
|
||||
│ ├── utils/ # 工具
|
||||
│ │ ├── request.ts # axios 封装
|
||||
│ │ ├── date.ts # dayjs 封装
|
||||
│ │ ├── dict.ts # 业务字典
|
||||
│ │ └── bus.ts # mitt 事件总线
|
||||
│ ├── views/ # 页面
|
||||
│ │ ├── login/index.vue
|
||||
│ │ ├── dashboard/index.vue
|
||||
│ │ ├── product/
|
||||
│ │ ├── order/
|
||||
│ │ ├── user/
|
||||
│ │ ├── coupon/
|
||||
│ │ ├── seckill/
|
||||
│ │ ├── notice/
|
||||
│ │ ├── chat/
|
||||
│ │ └── error/404.vue
|
||||
│ ├── App.vue
|
||||
│ └── main.ts
|
||||
├── .env.development # 开发环境变量
|
||||
├── .env.test # 测试环境变量
|
||||
├── .env.production # 生产环境变量
|
||||
├── eslint.config.js
|
||||
├── .prettierrc.json
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── tsconfig.node.json
|
||||
├── uno.config.ts
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
## 设计系统
|
||||
|
||||
| 项 | 值 |
|
||||
|----|----|
|
||||
| 风格 | Minimalism · Data-Dense Dashboard |
|
||||
| 主色 | `#1E40AF`(深蓝)/ `#3B82F6`(亮蓝) |
|
||||
| 辅色 | `#60A5FA` |
|
||||
| 状态色 | success `#10B981` / warning `#F59E0B` / danger `#EF4444` |
|
||||
| 字体 | 系统字体栈(PingFang SC / Microsoft YaHei) |
|
||||
| 圆角 | 卡片 `10px`、按钮 `6px`、输入框 `10px` |
|
||||
| 阴影 | 三档(sm / md / lg) |
|
||||
| 背景 | 页面 `#F8FAFC`、卡片 `#FFFFFF` |
|
||||
|
||||
## 后端联调
|
||||
|
||||
`.env.development` 中配置:
|
||||
|
||||
```
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
`vite.config.ts` 已配置 `/api` 与 `/uploads` 路径代理到后端 `server-snack` 项目。
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
现代浏览器 + ES2020(Chrome 90+, Edge 90+, Firefox 90+, Safari 14+)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import pluginVue from 'eslint-plugin-vue'
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript'
|
||||
|
||||
export default [
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}']
|
||||
},
|
||||
{
|
||||
name: 'app/files-to-ignore',
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**']
|
||||
},
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
...vueTsEslintConfig()
|
||||
]
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="零食商城管理后台" />
|
||||
<title>零食商城管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"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": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.5.1",
|
||||
"element-plus": "^2.9.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.3.0",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-router": "^4.5.0",
|
||||
"wangeditor": "^4.7.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mockjs": "^1.0.10",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"@vue/eslint-config-typescript": "^14.1.4",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"mockjs": "^1.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"sass": "^1.83.0",
|
||||
"typescript": "^5.7.2",
|
||||
"unocss": "^0.65.4",
|
||||
"unplugin-auto-import": "^19.0.0",
|
||||
"unplugin-vue-components": "^28.0.0",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3B82F6" />
|
||||
<stop offset="100%" stop-color="#1E40AF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#g)" />
|
||||
<text x="50%" y="58%" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="28" fill="#fff">S</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 455 B |
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { request } from '@/utils/request'
|
||||
import type { LoginParams, LoginResult, AdminInfo } from '@/types/auth'
|
||||
|
||||
/** 登录 */
|
||||
export function loginApi(params: LoginParams) {
|
||||
return request<LoginResult>({
|
||||
url: '/api/admin/login',
|
||||
method: 'POST',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
|
||||
/** 登出 */
|
||||
export function logoutApi() {
|
||||
return request<void>({
|
||||
url: '/api/admin/logout',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取当前管理员信息 */
|
||||
export function getAdminInfoApi() {
|
||||
return request<AdminInfo>({
|
||||
url: '/api/admin/info',
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取图形验证码 */
|
||||
export function getCaptchaApi() {
|
||||
return request<{ key: string; image: string }>({
|
||||
url: '/api/admin/captcha',
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PageResult<ChatSession>>({
|
||||
url: '/api/admin/chat/session/page',
|
||||
method: 'GET',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getChatMessagesApi(sessionId: number) {
|
||||
return request<ChatMessage[]>({
|
||||
url: `/api/admin/chat/session/${sessionId}/messages`,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
export function sendChatMessageApi(sessionId: number, data: { type: string; content: string }) {
|
||||
return request<void>({
|
||||
url: `/api/admin/chat/session/${sessionId}/message`,
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PageResult<CouponItem>>({
|
||||
url: '/api/admin/coupon/page',
|
||||
method: 'GET',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createCouponApi(data: Partial<CouponItem>) {
|
||||
return request<void>({
|
||||
url: '/api/admin/coupon',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCouponStatusApi(id: number, status: 0 | 1) {
|
||||
return request<void>({
|
||||
url: `/api/admin/coupon/${id}/status`,
|
||||
method: 'PUT',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
|
@ -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' }
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PageResult<NoticeItem>>({
|
||||
url: '/api/admin/notice/page',
|
||||
method: 'GET',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createNoticeApi(data: Partial<NoticeItem> & { content: string }) {
|
||||
return request<void>({
|
||||
url: '/api/admin/notice',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNoticeStatusApi(id: number, status: 0 | 1) {
|
||||
return request<void>({
|
||||
url: `/api/admin/notice/${id}/status`,
|
||||
method: 'PUT',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PageResult<OrderItem>>({
|
||||
url: '/api/admin/order/page',
|
||||
method: 'GET',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function deliverOrderApi(id: number, data: { company: string; trackingNo: string }) {
|
||||
return request<void>({
|
||||
url: `/api/admin/order/${id}/deliver`,
|
||||
method: 'PUT',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelOrderApi(id: number) {
|
||||
return request<void>({
|
||||
url: `/api/admin/order/${id}/cancel`,
|
||||
method: 'PUT'
|
||||
})
|
||||
}
|
||||
|
|
@ -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<CategoryItem[]>({
|
||||
url: '/api/admin/category/list',
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
export function getProductPageApi(params: PageParams) {
|
||||
return request<PageResult<ProductItem>>({
|
||||
url: '/api/admin/product/page',
|
||||
method: 'GET',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProductStatusApi(id: number, status: 0 | 1) {
|
||||
return request<void>({
|
||||
url: `/api/admin/product/${id}/status`,
|
||||
method: 'PUT',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PageResult<SeckillActivity>>({
|
||||
url: '/api/admin/seckill/page',
|
||||
method: 'GET',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createSeckillApi(data: Partial<SeckillActivity>) {
|
||||
return request<void>({
|
||||
url: '/api/admin/seckill',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function endSeckillApi(id: number) {
|
||||
return request<void>({
|
||||
url: `/api/admin/seckill/${id}/end`,
|
||||
method: 'PUT'
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PageResult<UserItem>>({
|
||||
url: '/api/admin/user/page',
|
||||
method: 'GET',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function updateUserStatusApi(id: number, status: 0 | 1) {
|
||||
return request<void>({
|
||||
url: `/api/admin/user/${id}/status`,
|
||||
method: 'PUT',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// 业务层主样式入口
|
||||
// variables.scss 已由 vite.config.ts 的 additionalData 自动注入
|
||||
|
||||
// 通用工具类
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// 通用页面容器
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 通用页面头部
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
.page-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// 通用搜索栏
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
|
||||
// 通用状态点
|
||||
.status-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
.status-dot.success { color: $color-success; }
|
||||
.status-dot.warning { color: $color-warning; }
|
||||
.status-dot.danger { color: $color-danger; }
|
||||
.status-dot.info { color: $color-info; }
|
||||
.status-dot.primary { color: $brand-primary; }
|
||||
|
||||
// 表格内图片
|
||||
.cell-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: $bg-page;
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用搜索栏容器
|
||||
*/
|
||||
defineProps<{ showDivider?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['search-bar-wrapper', { 'with-divider': showDivider }]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-bar-wrapper {
|
||||
padding: 16px 20px;
|
||||
&.with-divider {
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Expand,
|
||||
Fold,
|
||||
ArrowDown,
|
||||
SwitchButton,
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
ChatDotRound
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
import NavBreadcrumb from './components/NavBreadcrumb.vue'
|
||||
import TabsNav from './components/TabsNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||
|
||||
/**
|
||||
* 路由视图刷新控制:监听 TabsNav 派发的 'app:refresh-view' 事件
|
||||
* 通过 v-if 短暂卸载再挂载 router-view,达到刷新当前页面的效果
|
||||
*/
|
||||
const viewKey = ref(0)
|
||||
|
||||
function onRefreshView() {
|
||||
viewKey.value++
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
const width = window.innerWidth
|
||||
appStore.toggleDevice(width < 992 ? 'mobile' : 'desktop')
|
||||
if (width < 992) appStore.sidebarCollapsed = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onResize()
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('app:refresh-view', onRefreshView)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('app:refresh-view', onRefreshView)
|
||||
})
|
||||
|
||||
async function handleCommand(cmd: string) {
|
||||
if (cmd === 'logout') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
} catch {
|
||||
/* 用户取消 */
|
||||
}
|
||||
} else if (cmd === 'profile') {
|
||||
router.push('/profile')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="basic-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="collapsed ? '64px' : '220px'" class="sidebar">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">S</div>
|
||||
<transition name="fade">
|
||||
<span v-if="!collapsed" class="logo-text">零食商城</span>
|
||||
</transition>
|
||||
</div>
|
||||
<SideMenu :collapsed="collapsed" />
|
||||
</el-aside>
|
||||
|
||||
<el-container class="right-container">
|
||||
<!-- 顶栏 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-button text class="collapse-btn" @click="toggleSidebar">
|
||||
<el-icon :size="20">
|
||||
<component :is="collapsed ? Expand : Fold" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<NavBreadcrumb />
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tooltip content="公告" placement="bottom">
|
||||
<el-button text :icon="Bell" circle />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="客服消息" placement="bottom">
|
||||
<el-button text :icon="ChatDotRound" circle />
|
||||
</el-tooltip>
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="userStore.avatar">
|
||||
<el-icon><UserIcon /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.username || '管理员' }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><UserIcon /></el-icon> 个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<el-icon><SwitchButton /></el-icon> 退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 标签导航栏 -->
|
||||
<TabsNav />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main class="main">
|
||||
<router-view v-slot="{ Component, route: r }">
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<keep-alive>
|
||||
<component :is="Component" :key="viewKey + '-' + r.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-layout {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, #1e3a8a 0%, #1e40af 100%);
|
||||
transition: width 0.25s ease;
|
||||
overflow: hidden;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: $header-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.right-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
height: $header-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $bg-hover;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
background: $bg-page;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched.filter((r) => r.meta?.title)
|
||||
return matched.map((r, idx) => ({
|
||||
title: r.meta.title as string,
|
||||
isLast: idx === matched.length - 1
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item v-for="b in breadcrumbs" :key="b.title">
|
||||
<span :class="{ 'is-last': b.isLast }">{{ b.title }}</span>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
|
||||
:deep(.is-last) {
|
||||
color: $color-text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
collapsed?: boolean
|
||||
}
|
||||
defineProps<Props>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** 从路由表提取菜单项 */
|
||||
function getMenuItems(routes: RouteRecordRaw[]) {
|
||||
const items: any[] = []
|
||||
routes.forEach((r) => {
|
||||
if (r.meta?.hidden) return
|
||||
if (r.children?.length) {
|
||||
items.push(...getMenuItems(r.children))
|
||||
} else {
|
||||
items.push({
|
||||
path: r.path,
|
||||
name: r.name as string,
|
||||
title: r.meta?.title as string,
|
||||
icon: r.meta?.icon as string
|
||||
})
|
||||
}
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
// 从路由表提取菜单项:过滤掉 hidden 和通配路由,递归处理子路由
|
||||
const menuItems = computed(() => {
|
||||
const topRoutes = router.options.routes.filter(
|
||||
(r) => !r.meta?.hidden && r.path !== '/:pathMatch(.*)*'
|
||||
)
|
||||
return getMenuItems(topRoutes as RouteRecordRaw[])
|
||||
})
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="activePath"
|
||||
:collapse="collapsed"
|
||||
background-color="transparent"
|
||||
text-color="rgba(255,255,255,0.85)"
|
||||
active-text-color="#fff"
|
||||
router
|
||||
class="side-menu"
|
||||
unique-opened
|
||||
>
|
||||
<el-menu-item v-for="m in menuItems" :key="m.path" :index="m.path">
|
||||
<el-icon v-if="m.icon"><component :is="m.icon" /></el-icon>
|
||||
<template #title>{{ m.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.side-menu {
|
||||
border-right: none;
|
||||
height: calc(100vh - #{$header-height});
|
||||
background: transparent !important;
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
margin: 4px 12px;
|
||||
border-radius: 8px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(255, 255, 255, 0.18) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Close, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { useTabsStore } from '@/stores/modules/tabs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
// 路由变化时自动添加 tab
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
tabsStore.addTab(route)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleTabClick(path: string) {
|
||||
if (path === route.path) return
|
||||
tabsStore.setActive(path)
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function handleClose(e: MouseEvent, path: string) {
|
||||
e.stopPropagation()
|
||||
const nextPath = tabsStore.closeTab(path, route.path)
|
||||
if (nextPath !== route.path) {
|
||||
router.push(nextPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新当前页 — 通过自定义事件通知 BasicLayout 重新挂载 <router-view>
|
||||
* 不使用 router.replace({ path: '/redirect' + ... }),因为我们没有注册 /redirect/* 路由
|
||||
* 会触发通配符跳到 /404
|
||||
*/
|
||||
function handleRefresh() {
|
||||
window.dispatchEvent(new CustomEvent('app:refresh-view'))
|
||||
}
|
||||
|
||||
function handleCloseOthers() {
|
||||
tabsStore.closeOthers(route.path)
|
||||
}
|
||||
|
||||
function handleCloseRight() {
|
||||
tabsStore.closeRight(route.path)
|
||||
}
|
||||
|
||||
function handleCloseAll() {
|
||||
tabsStore.closeAll()
|
||||
router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs-nav">
|
||||
<div class="tabs-scroll">
|
||||
<div
|
||||
v-for="tab in tabsStore.tabs"
|
||||
:key="tab.path"
|
||||
:class="['tab-item', { active: tab.path === tabsStore.activeTab }]"
|
||||
@click="handleTabClick(tab.path)"
|
||||
>
|
||||
<span v-if="tab.path === tabsStore.activeTab" class="tab-dot" />
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<el-icon
|
||||
v-if="tab.path !== '/dashboard'"
|
||||
class="tab-close"
|
||||
@click="handleClose($event, tab.path)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs-actions">
|
||||
<el-tooltip content="刷新当前页" placement="bottom">
|
||||
<el-button text size="small" :icon="RefreshRight" @click="handleRefresh" />
|
||||
</el-tooltip>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button text size="small">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleCloseOthers">关闭其他</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleCloseRight">关闭右侧</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleCloseAll">关闭所有</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabs-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
padding: 0 4px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs-scroll {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: $brand-primary;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $brand-primary;
|
||||
background: rgba(30, 64, 175, 0.08);
|
||||
border-color: rgba(30, 64, 175, 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $brand-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
font-size: 12px;
|
||||
color: $color-text-placeholder;
|
||||
border-radius: 3px;
|
||||
padding: 1px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: $color-danger;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
border-left: 1px solid $color-border-light;
|
||||
padding-left: 8px;
|
||||
flex-shrink: 0;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
// 状态管理
|
||||
import pinia from './stores'
|
||||
|
||||
// 路由
|
||||
import router from './router'
|
||||
|
||||
// 全局样式(分开 import,每个文件都能获得 additionalData 注入的变量)
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './styles/reset.scss'
|
||||
import './styles/element.scss'
|
||||
import './styles/transitions.scss'
|
||||
import 'virtual:uno.css'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 业务样式
|
||||
import './assets/styles/main.scss'
|
||||
|
||||
// 权限控制
|
||||
import './permission'
|
||||
|
||||
// Mock 拦截(开发阶段用本地假数据,启动后端后可注释此行)
|
||||
import './mock'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import Mock from 'mockjs'
|
||||
|
||||
/**
|
||||
* 全局 mock 数据
|
||||
* 集中管理所有页面所需的假数据,便于查看效果
|
||||
*/
|
||||
|
||||
// 商品分类(树形)
|
||||
export const mockCategories = [
|
||||
{ id: 1, name: '休闲零食', parentId: 0, level: 1, sort: 1 },
|
||||
{ id: 2, name: '坚果炒货', parentId: 1, level: 2, sort: 1, icon: '' },
|
||||
{ id: 3, name: '糖果巧克力', parentId: 1, level: 2, sort: 2 },
|
||||
{ id: 4, name: '肉脯卤味', parentId: 1, level: 2, sort: 3 },
|
||||
{ id: 5, name: '膨化食品', parentId: 1, level: 2, sort: 4 },
|
||||
{ id: 6, name: '蜜饯果干', parentId: 1, level: 2, sort: 5 },
|
||||
{ id: 7, name: '饮料饮品', parentId: 0, level: 1, sort: 2 }
|
||||
]
|
||||
|
||||
// 商品列表
|
||||
export const mockProducts = Mock.mock({
|
||||
'list|30': [
|
||||
{
|
||||
'id|+1': 1,
|
||||
name: '@ctitle(4, 8)',
|
||||
categoryId: '@integer(2, 7)',
|
||||
mainImage: Mock.Random.image('120x120', Mock.Random.color(), '#fff', 'png', '商品'),
|
||||
price: '@float(10, 200, 2, 2)',
|
||||
stock: '@integer(0, 999)',
|
||||
sales: '@integer(0, 5000)',
|
||||
status: '@integer(0, 1)',
|
||||
createTime: '@datetime("yyyy-MM-dd HH:mm:ss")'
|
||||
}
|
||||
]
|
||||
}).list
|
||||
|
||||
// 用户列表
|
||||
export const mockUsers = Mock.mock({
|
||||
'list|25': [
|
||||
{
|
||||
'id|+1': 1001,
|
||||
username: '@cname',
|
||||
phone: /^1[3-9]\d{9}$/,
|
||||
avatar: Mock.Random.image('80x80', Mock.Random.color(), '#fff', 'png', 'U'),
|
||||
status: '@integer(0, 1)',
|
||||
createTime: '@datetime("yyyy-MM-dd HH:mm:ss")'
|
||||
}
|
||||
]
|
||||
}).list
|
||||
|
||||
// 订单列表
|
||||
export const mockOrders = Mock.mock({
|
||||
'list|20': [
|
||||
{
|
||||
'id|+1': 1,
|
||||
orderNo: 'SN@date("yyyyMMdd")@string("number", 6)',
|
||||
userId: '@integer(1001, 1050)',
|
||||
username: '@cname',
|
||||
totalAmount: '@float(50, 800, 2, 2)',
|
||||
payAmount: '@float(50, 800, 2, 2)',
|
||||
status: '@integer(0, 4)',
|
||||
receiverName: '@cname',
|
||||
receiverPhone: /^1[3-9]\d{9}$/,
|
||||
createTime: '@datetime("yyyy-MM-dd HH:mm:ss")'
|
||||
}
|
||||
]
|
||||
}).list
|
||||
|
||||
// 优惠券列表
|
||||
export const mockCoupons = Mock.mock({
|
||||
'list|12': [
|
||||
{
|
||||
'id|+1': 1,
|
||||
name: '@ctitle(6, 12)券',
|
||||
type: '@integer(0, 2)',
|
||||
amount: '@float(5, 100, 0, 2)',
|
||||
minAmount: '@float(0, 200, 0, 2)',
|
||||
total: '@integer(100, 1000)',
|
||||
remain: '@integer(0, 500)',
|
||||
status: '@integer(0, 2)',
|
||||
startTime: '@datetime("yyyy-MM-dd HH:mm:ss")',
|
||||
endTime: '@datetime("yyyy-MM-dd HH:mm:ss")'
|
||||
}
|
||||
]
|
||||
}).list
|
||||
|
||||
// 公告列表
|
||||
export const mockNotices = Mock.mock({
|
||||
'list|10': [
|
||||
{
|
||||
'id|+1': 1,
|
||||
title: '@ctitle(8, 18)',
|
||||
type: '@integer(0, 2)',
|
||||
isTop: '@integer(0, 1)',
|
||||
status: '@integer(0, 1)',
|
||||
startTime: '@datetime("yyyy-MM-dd HH:mm:ss")',
|
||||
endTime: '@datetime("yyyy-MM-dd HH:mm:ss")',
|
||||
createTime: '@datetime("yyyy-MM-dd HH:mm:ss")'
|
||||
}
|
||||
]
|
||||
}).list
|
||||
|
||||
// 抢购活动
|
||||
export const mockSeckillActivities = Mock.mock({
|
||||
'list|6': [
|
||||
{
|
||||
'id|+1': 1,
|
||||
name: '@ctitle(6, 10) 限时抢购',
|
||||
startTime: '@datetime("yyyy-MM-dd HH:mm:ss")',
|
||||
endTime: '@datetime("yyyy-MM-dd HH:mm:ss")',
|
||||
status: '@integer(0, 2)',
|
||||
createTime: '@datetime("yyyy-MM-dd HH:mm:ss")'
|
||||
}
|
||||
]
|
||||
}).list
|
||||
|
||||
// 客服会话
|
||||
export const mockChatSessions = Mock.mock({
|
||||
'list|8': [
|
||||
{
|
||||
'id|+1': 1,
|
||||
sessionNo: 'CS@date("yyyyMMdd")@string("number", 4)',
|
||||
userId: '@integer(1001, 1100)',
|
||||
username: '@cname',
|
||||
adminId: null,
|
||||
status: '@integer(0, 2)',
|
||||
lastMessage: '@csentence(5, 12)',
|
||||
unreadCount: '@integer(0, 5)',
|
||||
updateTime: '@datetime("yyyy-MM-dd HH:mm:ss")'
|
||||
}
|
||||
]
|
||||
}).list
|
||||
|
||||
// 仪表盘数据
|
||||
export const mockDashboardStats = {
|
||||
todayOrders: Mock.Random.integer(80, 200),
|
||||
todaySales: Mock.Random.float(5000, 12000, 2, 2),
|
||||
totalUsers: Mock.Random.integer(1500, 3000),
|
||||
totalProducts: Mock.Random.integer(200, 500)
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import MockAdapter from 'axios-mock-adapter'
|
||||
import service from '@/utils/request'
|
||||
import {
|
||||
mockCategories,
|
||||
mockProducts,
|
||||
mockUsers,
|
||||
mockOrders,
|
||||
mockCoupons,
|
||||
mockNotices,
|
||||
mockSeckillActivities,
|
||||
mockChatSessions,
|
||||
mockDashboardStats
|
||||
} from './data'
|
||||
|
||||
const mock = new MockAdapter(service, { delayResponse: 300 })
|
||||
|
||||
/**
|
||||
* 统一成功响应
|
||||
*/
|
||||
const ok = (data: unknown) => [200, { code: 200, message: '操作成功', data }]
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
mock.onPost('/api/admin/login').reply((config) => {
|
||||
const body = JSON.parse(config.data || '{}')
|
||||
if (body.username === 'admin' && body.password === '123456') {
|
||||
return ok({
|
||||
token: 'mock-token-' + Date.now()
|
||||
})
|
||||
}
|
||||
return [200, { code: 1004, message: '用户名或密码错误', data: null }]
|
||||
})
|
||||
|
||||
/** 登出 */
|
||||
mock.onPost('/api/admin/logout').reply(() => ok(null))
|
||||
|
||||
/** 当前管理员信息 */
|
||||
mock.onGet('/api/admin/info').reply(() =>
|
||||
ok({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nickname: '超级管理员',
|
||||
avatar: '',
|
||||
role: 'super_admin',
|
||||
permissions: ['*']
|
||||
})
|
||||
)
|
||||
|
||||
/** 仪表盘 */
|
||||
mock.onGet('/api/admin/dashboard/stats').reply(() => ok(mockDashboardStats))
|
||||
mock.onGet(/\/api\/admin\/dashboard\/sales-trend/).reply(() =>
|
||||
ok(
|
||||
Array.from({ length: 7 }).map((_, i) => ({
|
||||
date: `6/${i + 1}`,
|
||||
amount: Mock.Random.float(5000, 10000, 2, 0)
|
||||
}))
|
||||
)
|
||||
)
|
||||
mock.onGet('/api/admin/dashboard/order-status').reply(() =>
|
||||
ok([
|
||||
{ name: '待付款', value: 28 },
|
||||
{ name: '待发货', value: 35 },
|
||||
{ name: '待收货', value: 22 },
|
||||
{ name: '已完成', value: 18 },
|
||||
{ name: '已取消', value: 7 }
|
||||
])
|
||||
)
|
||||
mock.onGet('/api/admin/dashboard/hot-products').reply(() =>
|
||||
ok(
|
||||
['夏威夷果', '芒果干', '猪肉脯', '薯片', '巧克力'].map((name) => ({
|
||||
name,
|
||||
sales: Mock.Random.integer(100, 400)
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
/** 通用分页 */
|
||||
function paginate<T>(list: T[], current = 1, size = 10) {
|
||||
const total = list.length
|
||||
const pages = Math.ceil(total / size)
|
||||
const records = list.slice((current - 1) * size, current * size)
|
||||
return { records, total, size, current, pages }
|
||||
}
|
||||
|
||||
/** 商品分类 */
|
||||
mock.onGet('/api/admin/category/list').reply(() => ok(mockCategories))
|
||||
|
||||
/** 商品分页 */
|
||||
mock.onGet('/api/admin/product/page').reply((config) => {
|
||||
const params = config.params || {}
|
||||
return ok(paginate(mockProducts, params.current || 1, params.size || 10))
|
||||
})
|
||||
mock.onPut(/\/api\/admin\/product\/\d+\/status/).reply(() => ok(null))
|
||||
|
||||
/** 用户分页 */
|
||||
mock.onGet('/api/admin/user/page').reply((config) => {
|
||||
const params = config.params || {}
|
||||
return ok(paginate(mockUsers, params.current || 1, params.size || 10))
|
||||
})
|
||||
mock.onPut(/\/api\/admin\/user\/\d+\/status/).reply(() => ok(null))
|
||||
|
||||
/** 订单分页 */
|
||||
mock.onGet('/api/admin/order/page').reply((config) => {
|
||||
const params = config.params || {}
|
||||
return ok(paginate(mockOrders, params.current || 1, params.size || 10))
|
||||
})
|
||||
mock.onPut(/\/api\/admin\/order\/\d+\/(deliver|cancel)/).reply(() => ok(null))
|
||||
|
||||
/** 优惠券 */
|
||||
mock.onGet('/api/admin/coupon/page').reply((config) => {
|
||||
const params = config.params || {}
|
||||
return ok(paginate(mockCoupons, params.current || 1, params.size || 10))
|
||||
})
|
||||
mock.onPost('/api/admin/coupon').reply(() => ok(null))
|
||||
mock.onPut(/\/api\/admin\/coupon\/\d+\/status/).reply(() => ok(null))
|
||||
|
||||
/** 公告 */
|
||||
mock.onGet('/api/admin/notice/page').reply((config) => {
|
||||
const params = config.params || {}
|
||||
return ok(paginate(mockNotices, params.current || 1, params.size || 10))
|
||||
})
|
||||
mock.onPost('/api/admin/notice').reply(() => ok(null))
|
||||
mock.onPut(/\/api\/admin\/notice\/\d+\/status/).reply(() => ok(null))
|
||||
|
||||
/** 抢购 */
|
||||
mock.onGet('/api/admin/seckill/page').reply((config) => {
|
||||
const params = config.params || {}
|
||||
return ok(paginate(mockSeckillActivities, params.current || 1, params.size || 10))
|
||||
})
|
||||
mock.onPost('/api/admin/seckill').reply(() => ok(null))
|
||||
mock.onPut(/\/api\/admin\/seckill\/\d+\/end/).reply(() => ok(null))
|
||||
|
||||
/** 客服 */
|
||||
mock.onGet('/api/admin/chat/session/page').reply((config) => {
|
||||
const params = config.params || {}
|
||||
return ok(paginate(mockChatSessions, params.current || 1, params.size || 10))
|
||||
})
|
||||
mock.onGet(/\/api\/admin\/chat\/session\/\d+\/messages/).reply(() =>
|
||||
ok([
|
||||
{ id: 1, sessionId: 1, senderType: 0, type: 'text', content: '你好,请问夏威夷果还有货吗?', createTime: '2026-06-01 14:00:00' },
|
||||
{ id: 2, sessionId: 1, senderType: 1, type: 'text', content: '亲,在的哦,目前库存充足~', createTime: '2026-06-01 14:01:00' }
|
||||
])
|
||||
)
|
||||
mock.onPost(/\/api\/admin\/chat\/session\/\d+\/message/).reply(() => ok(null))
|
||||
|
||||
console.log('[Mock] 接口拦截已启用 ✓')
|
||||
export default mock
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
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()
|
||||
|
||||
// 白名单(login/404)
|
||||
if (WHITE_LIST.includes(to.path)) {
|
||||
if (to.path === '/login' && userStore.isLoggedIn) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 未登录 → 跳登录
|
||||
if (!userStore.isLoggedIn) {
|
||||
next(`/login?redirect=${encodeURIComponent(to.path)}`)
|
||||
NProgress.done()
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录但未拉取过管理员信息 → 拉取一次
|
||||
if (!userStore.adminInfo) {
|
||||
try {
|
||||
await userStore.fetchAdminInfo()
|
||||
} catch (err) {
|
||||
userStore.clearAuth()
|
||||
next(`/login?redirect=${encodeURIComponent(to.path)}`)
|
||||
NProgress.done()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default asyncRoutes
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
routes: [
|
||||
// ==================== 公开路由 ====================
|
||||
{
|
||||
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 }
|
||||
},
|
||||
|
||||
// ==================== 仪表盘 ====================
|
||||
{
|
||||
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: '/product/list',
|
||||
name: 'ProductList',
|
||||
component: () => import('@/views/product/list.vue'),
|
||||
meta: { title: '商品列表', icon: 'List' }
|
||||
},
|
||||
{
|
||||
path: '/product/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: '/order/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: '/user/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: '/marketing/coupon',
|
||||
name: 'CouponList',
|
||||
component: () => import('@/views/coupon/list.vue'),
|
||||
meta: { title: '优惠券', icon: 'Ticket' }
|
||||
},
|
||||
{
|
||||
path: '/marketing/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: '/notice/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: '/chat/list',
|
||||
name: 'ChatList',
|
||||
component: () => import('@/views/chat/list.vue'),
|
||||
meta: { title: '客服消息', icon: 'ChatDotRound' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ==================== 404 兜底(放最后) ====================
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
export default pinia
|
||||
|
|
@ -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']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
export interface TabItem {
|
||||
path: string
|
||||
title: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// 首页 tab 始终保留
|
||||
const HOME_TAB: TabItem = {
|
||||
path: '/dashboard',
|
||||
title: '仪表盘',
|
||||
name: 'Dashboard'
|
||||
}
|
||||
|
||||
export const useTabsStore = defineStore(
|
||||
'tabs',
|
||||
() => {
|
||||
const tabs = ref<TabItem[]>([{ ...HOME_TAB }])
|
||||
const activeTab = ref('/dashboard')
|
||||
|
||||
function addTab(route: RouteLocationNormalizedLoaded) {
|
||||
const title = (route.meta?.title as string) || route.name as string
|
||||
const path = route.path
|
||||
if (!title || path === '/') return
|
||||
const exists = tabs.value.some((t) => t.path === path)
|
||||
if (!exists) {
|
||||
tabs.value.push({
|
||||
path,
|
||||
title,
|
||||
name: route.name as string
|
||||
})
|
||||
}
|
||||
activeTab.value = path
|
||||
}
|
||||
|
||||
function closeTab(path: string, currentPath: string): string {
|
||||
// 不允许关闭首页
|
||||
if (path === HOME_TAB.path) return currentPath
|
||||
|
||||
const idx = tabs.value.findIndex((t) => t.path === path)
|
||||
if (idx === -1) return currentPath
|
||||
tabs.value.splice(idx, 1)
|
||||
|
||||
// 如果关闭的是当前激活 tab,则跳转到相邻 tab
|
||||
if (path === currentPath) {
|
||||
const nextTab = tabs.value[idx] || tabs.value[idx - 1]
|
||||
const nextPath = nextTab ? nextTab.path : HOME_TAB.path
|
||||
activeTab.value = nextPath
|
||||
return nextPath
|
||||
}
|
||||
return currentPath
|
||||
}
|
||||
|
||||
function closeOthers(path: string) {
|
||||
// 只保留首页和当前 tab
|
||||
tabs.value = tabs.value.filter(
|
||||
(t) => t.path === HOME_TAB.path || t.path === path
|
||||
)
|
||||
activeTab.value = path
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
tabs.value = [{ ...HOME_TAB }]
|
||||
activeTab.value = HOME_TAB.path
|
||||
}
|
||||
|
||||
function closeRight(path: string) {
|
||||
const idx = tabs.value.findIndex((t) => t.path === path)
|
||||
if (idx === -1) return
|
||||
tabs.value = tabs.value.slice(0, idx + 1)
|
||||
activeTab.value = path
|
||||
}
|
||||
|
||||
function setActive(path: string) {
|
||||
activeTab.value = path
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
addTab,
|
||||
closeTab,
|
||||
closeOthers,
|
||||
closeAll,
|
||||
closeRight,
|
||||
setActive
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'snack-admin-tabs',
|
||||
pick: ['tabs', 'activeTab']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
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<string>('')
|
||||
const adminInfo = ref<AdminInfo | null>(null)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const username = computed(() => adminInfo.value?.username ?? '')
|
||||
const avatar = computed(() => adminInfo.value?.avatar ?? '')
|
||||
|
||||
async function login(params: LoginParams) {
|
||||
// request 拦截器已解包出业务 data,这里直接拿到 { token }
|
||||
const data = await loginApi(params)
|
||||
token.value = data.token
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchAdminInfo() {
|
||||
// request 拦截器已解包出业务 data,这里直接拿到 AdminInfo
|
||||
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']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// Element Plus 主题覆盖 — 统一品牌色 + 圆角风格
|
||||
// variables.scss 已由 vite.config.ts 的 additionalData 自动注入
|
||||
|
||||
|
||||
/* 主按钮 */
|
||||
.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// 此文件已弃用
|
||||
// 全局样式改为在 main.ts 中分开 import 各子文件
|
||||
// 每个子文件由 Vite 独立处理,additionalData 中注入的变量可正常访问
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* 后端 Result 响应结构
|
||||
*/
|
||||
export interface Result<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应数据
|
||||
*/
|
||||
export interface PageResult<T = unknown> {
|
||||
records: T[]
|
||||
total: number
|
||||
size: number
|
||||
current: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页请求参数
|
||||
*/
|
||||
export interface PageParams {
|
||||
current: number
|
||||
size: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchBar: typeof import('./../components/common/SearchBar.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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<Events> = mitt<Events>()
|
||||
|
||||
export default emitter
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// 分页查询通用参数
|
||||
export const DEFAULT_PAGE = { current: 1, size: 10 }
|
||||
|
||||
// 重置分页
|
||||
export function resetPage() {
|
||||
return { ...DEFAULT_PAGE }
|
||||
}
|
||||
|
||||
// 关键字搜索防抖 hook 占位
|
||||
export const SEARCH_DEBOUNCE_MS = 300
|
||||
|
|
@ -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<T = unknown> {
|
||||
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<ApiResponse>) => {
|
||||
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<T = unknown>(config: AxiosRequestConfig): Promise<T> {
|
||||
return service.request<unknown, T>(config)
|
||||
}
|
||||
|
||||
export default service
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { Search, ChatDotRound, Promotion, Picture } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getChatSessionPageApi, getChatMessagesApi, sendChatMessageApi } from '@/api/chat'
|
||||
import type { ChatSession, ChatMessage } from '@/api/chat'
|
||||
import { fromNow } from '@/utils/date'
|
||||
|
||||
const sessionList = ref<ChatSession[]>([])
|
||||
const activeSession = ref<ChatSession | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
|
||||
async function fetchSessions() {
|
||||
const res: any = await getChatSessionPageApi({ current: 1, size: 20 })
|
||||
sessionList.value = res.records
|
||||
if (!activeSession.value && sessionList.value.length > 0) {
|
||||
selectSession(sessionList.value[0])
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSession(s: ChatSession) {
|
||||
activeSession.value = s
|
||||
const res: any = await getChatMessagesApi(s.id)
|
||||
messages.value = res
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!inputText.value.trim() || !activeSession.value) return
|
||||
const content = inputText.value
|
||||
inputText.value = ''
|
||||
// 立即本地显示
|
||||
messages.value.push({
|
||||
id: Date.now(),
|
||||
sessionId: activeSession.value.id,
|
||||
senderType: 1,
|
||||
type: 'text',
|
||||
content,
|
||||
createTime: new Date().toLocaleString('zh-CN')
|
||||
})
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
// 异步推送
|
||||
try {
|
||||
await sendChatMessageApi(activeSession.value.id, { type: 'text', content })
|
||||
} catch (e) {
|
||||
ElMessage.error('发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchSessions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container chat-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>客服消息</h2>
|
||||
<p class="page-desc">实时处理用户咨询,支持文字/图片消息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-container">
|
||||
<!-- 会话列表 -->
|
||||
<div class="session-list">
|
||||
<div class="session-header">
|
||||
<el-input
|
||||
placeholder="搜索用户"
|
||||
:prefix-icon="Search"
|
||||
size="default"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="session-scroll">
|
||||
<div
|
||||
v-for="s in sessionList"
|
||||
:key="s.id"
|
||||
:class="['session-item', { active: activeSession?.id === s.id }]"
|
||||
@click="selectSession(s)"
|
||||
>
|
||||
<el-avatar :size="40">{{ s.username.charAt(0) }}</el-avatar>
|
||||
<div class="session-info">
|
||||
<div class="session-top">
|
||||
<span class="session-name">{{ s.username }}</span>
|
||||
<span class="session-time">{{ fromNow(s.updateTime) }}</span>
|
||||
</div>
|
||||
<div class="session-bottom">
|
||||
<span class="last-msg">{{ s.lastMessage }}</span>
|
||||
<el-badge v-if="s.unreadCount > 0" :value="s.unreadCount" class="badge" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天窗口 -->
|
||||
<div class="chat-window">
|
||||
<template v-if="activeSession">
|
||||
<div class="chat-header">
|
||||
<el-avatar :size="36">{{ activeSession.username.charAt(0) }}</el-avatar>
|
||||
<div class="chat-user">
|
||||
<div class="chat-name">{{ activeSession.username }}</div>
|
||||
<div class="chat-no">会话号:{{ activeSession.sessionNo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messagesRef" class="chat-messages">
|
||||
<div
|
||||
v-for="m in messages"
|
||||
:key="m.id"
|
||||
:class="['msg-row', m.senderType === 1 ? 'mine' : 'theirs']"
|
||||
>
|
||||
<el-avatar v-if="m.senderType === 0" :size="32">
|
||||
{{ activeSession.username.charAt(0) }}
|
||||
</el-avatar>
|
||||
<div class="msg-bubble">{{ m.content }}</div>
|
||||
<el-avatar v-if="m.senderType === 1" :size="32" style="background: #1E40AF">
|
||||
客
|
||||
</el-avatar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="inputText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入回复内容..."
|
||||
@keydown.ctrl.enter="sendMessage"
|
||||
/>
|
||||
<div class="chat-actions">
|
||||
<el-button :icon="Picture" text>图片</el-button>
|
||||
<el-button :icon="Promotion" text>快捷回复</el-button>
|
||||
<el-button type="primary" :disabled="!inputText.trim()" @click="sendMessage">
|
||||
发送 (Ctrl+Enter)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="请选择会话" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 200px);
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.session-list {
|
||||
width: 280px;
|
||||
border-right: 1px solid $color-border-light;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.session-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
}
|
||||
.session-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.session-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: $bg-hover; }
|
||||
&.active { background: rgba(30, 64, 175, 0.08); }
|
||||
}
|
||||
.session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.session-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.session-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.session-time {
|
||||
font-size: 11px;
|
||||
color: $color-text-placeholder;
|
||||
}
|
||||
.session-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.last-msg {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
}
|
||||
.chat-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.chat-no {
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: $bg-page;
|
||||
}
|
||||
.msg-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
&.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
.msg-bubble {
|
||||
max-width: 60%;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.msg-row.mine .msg-bubble {
|
||||
background: $brand-primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
border-top: 1px solid $color-border-light;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
.chat-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Plus, Search, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getCouponPageApi, createCouponApi, updateCouponStatusApi } from '@/api/coupon'
|
||||
import type { CouponItem } from '@/api/coupon'
|
||||
import { COUPON_TYPE_MAP } from '@/utils/dict'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import SearchBar from '@/components/common/SearchBar.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<CouponItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const query = reactive({
|
||||
current: 1,
|
||||
size: 10
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'add' | 'edit'>('add')
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: 0,
|
||||
amount: 0,
|
||||
minAmount: 0,
|
||||
total: 100,
|
||||
perLimit: 1,
|
||||
startTime: '',
|
||||
endTime: ''
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getCouponPageApi(query)
|
||||
list.value = res.records
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
dialogMode.value = 'add'
|
||||
Object.assign(form, {
|
||||
id: 0,
|
||||
name: '',
|
||||
type: 0,
|
||||
amount: 0,
|
||||
minAmount: 0,
|
||||
total: 100,
|
||||
perLimit: 1,
|
||||
startTime: '',
|
||||
endTime: ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: CouponItem) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await createCouponApi(form)
|
||||
ElMessage.success(dialogMode.value === 'add' ? '创建成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleDelete(row: CouponItem) {
|
||||
await ElMessageBox.confirm(`确定要删除优惠券【${row.name}】吗?`, '提示', { type: 'warning' })
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function couponTagType(t: number) {
|
||||
return t === 0 ? 'danger' : t === 1 ? 'warning' : 'success'
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>优惠券管理</h2>
|
||||
<p class="page-desc">创建满减/折扣/无门槛优惠券,配置领取规则</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openAdd">创建优惠券</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<SearchBar show-divider>
|
||||
<el-button :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||
</SearchBar>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="name" label="名称" min-width="200" />
|
||||
<el-table-column label="类型" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="couponTagType(row.type) as any" size="small">
|
||||
{{ COUPON_TYPE_MAP[row.type as keyof typeof COUPON_TYPE_MAP] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面额/折扣" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="amount">
|
||||
{{ row.type === 1 ? `${(row.amount * 10).toFixed(1)}折` : `¥ ${row.amount}` }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="使用门槛" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.minAmount > 0">满 ¥ {{ row.minAmount }}</span>
|
||||
<span v-else class="text-muted">无门槛</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已领/总量" width="120" align="center">
|
||||
<template #default="{ row }">{{ row.total - row.remain }} / {{ row.total }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" width="280">
|
||||
<template #default="{ row }">
|
||||
<span class="time-cell">
|
||||
{{ formatDate(row.startTime, 'MM-DD HH:mm') }} ~ {{ formatDate(row.endTime, 'MM-DD HH:mm') }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" :icon="Edit" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small">领取记录</el-button>
|
||||
<el-button link type="danger" size="small" :icon="Delete" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="query.current"
|
||||
v-model:page-size="query.size"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'add' ? '创建优惠券' : '编辑优惠券'"
|
||||
width="560px"
|
||||
>
|
||||
<el-form :model="form" label-width="100px" size="large">
|
||||
<el-form-item label="优惠券名称" required>
|
||||
<el-input v-model="form.name" placeholder="如:满100减20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优惠券类型" required>
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio-button :value="0">满减券</el-radio-button>
|
||||
<el-radio-button :value="1">折扣券</el-radio-button>
|
||||
<el-radio-button :value="2">无门槛券</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="form.type === 1 ? '折扣率' : '面额'" required>
|
||||
<el-input-number
|
||||
v-model="form.amount"
|
||||
:min="0"
|
||||
:precision="form.type === 1 ? 1 : 0"
|
||||
:step="form.type === 1 ? 0.1 : 1"
|
||||
:max="form.type === 1 ? 1 : 9999"
|
||||
/>
|
||||
<span style="margin-left: 8px; color: #909399; font-size: 12px">
|
||||
{{ form.type === 1 ? '(0.9 = 9折)' : '元' }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="使用门槛" v-if="form.type !== 2">
|
||||
<el-input-number v-model="form.minAmount" :min="0" :step="10" />
|
||||
<span style="margin-left: 8px; color: #909399; font-size: 12px">满 ___ 元可用</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="发放总量" required>
|
||||
<el-input-number v-model="form.total" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="每人限领">
|
||||
<el-input-number v-model="form.perLimit" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期" required>
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
placeholder="开始时间"
|
||||
style="width: 45%"
|
||||
/>
|
||||
<span style="margin: 0 8px">至</span>
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
placeholder="结束时间"
|
||||
style="width: 45%"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.amount {
|
||||
color: $color-danger;
|
||||
font-weight: 600;
|
||||
}
|
||||
.text-muted {
|
||||
color: $color-text-placeholder;
|
||||
font-size: 12px;
|
||||
}
|
||||
.time-cell {
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, shallowRef } from 'vue'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Money,
|
||||
User,
|
||||
Goods,
|
||||
TrendCharts
|
||||
} from '@element-plus/icons-vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, PieChart, BarChart } from 'echarts/charts'
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent
|
||||
} from 'echarts/components'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
PieChart,
|
||||
BarChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent
|
||||
])
|
||||
|
||||
// 数据统计
|
||||
const stats = ref({
|
||||
todayOrders: 128,
|
||||
todaySales: 8965.5,
|
||||
totalUsers: 2356,
|
||||
totalProducts: 312
|
||||
})
|
||||
|
||||
const trendOption = shallowRef({})
|
||||
const pieOption = shallowRef({})
|
||||
const barOption = shallowRef({})
|
||||
|
||||
function buildCharts() {
|
||||
// 销售趋势(近 7 天)
|
||||
trendOption.value = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 30, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['6/1', '6/2', '6/3', '6/4', '6/5', '6/6', '6/7'],
|
||||
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||
axisLabel: { color: '#6B7280' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||
axisLabel: { color: '#6B7280' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '销售额',
|
||||
data: [6200, 5800, 7100, 8500, 9200, 7800, 8965],
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: { color: '#3B82F6', width: 3 },
|
||||
itemStyle: { color: '#3B82F6' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 订单状态饼图
|
||||
pieOption.value = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, icon: 'circle' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['50%', '75%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: [
|
||||
{ value: 28, name: '待付款', itemStyle: { color: '#F59E0B' } },
|
||||
{ value: 35, name: '待发货', itemStyle: { color: '#3B82F6' } },
|
||||
{ value: 22, name: '待收货', itemStyle: { color: '#1E40AF' } },
|
||||
{ value: 18, name: '已完成', itemStyle: { color: '#10B981' } },
|
||||
{ value: 7, name: '已取消', itemStyle: { color: '#9CA3AF' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 热销商品 Top10
|
||||
barOption.value = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 80, right: 30, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'value', axisLine: { show: false }, axisTick: { show: false } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: ['夏威夷果', '芒果干', '猪肉脯', '薯片', '巧克力', '瓜子', '核桃', '葡萄干', '开心果', '牛肉干'],
|
||||
axisLine: { lineStyle: { color: '#E5E7EB' } },
|
||||
axisLabel: { color: '#6B7280' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: [320, 280, 260, 240, 220, 200, 180, 160, 140, 120],
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 1, y2: 0,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#60A5FA' },
|
||||
{ offset: 1, color: '#1E40AF' }
|
||||
]
|
||||
},
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
buildCharts()
|
||||
})
|
||||
|
||||
const cards = [
|
||||
{ key: 'todayOrders', title: '今日订单数', icon: ShoppingCart, color: '#3B82F6', suffix: '单' },
|
||||
{ key: 'todaySales', title: '今日销售额', icon: Money, color: '#10B981', prefix: '¥' },
|
||||
{ key: 'totalUsers', title: '总用户数', icon: User, color: '#F59E0B', suffix: '人' },
|
||||
{ key: 'totalProducts', title: '总商品数', icon: Goods, color: '#1E40AF', suffix: '件' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="page-header">
|
||||
<h2>仪表盘</h2>
|
||||
<p>欢迎回来,这是您的运营概览</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据卡片 -->
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col v-for="c in cards" :key="c.key" :xs="12" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-icon" :style="{ background: c.color + '15', color: c.color }">
|
||||
<el-icon :size="24"><component :is="c.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-title">{{ c.title }}</div>
|
||||
<div class="stat-value">
|
||||
{{ c.prefix || '' }}{{ stats[c.key as keyof typeof stats] }}{{ c.suffix || '' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区 -->
|
||||
<el-row :gutter="20" class="chart-row">
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>销售趋势</span>
|
||||
<el-radio-group size="small">
|
||||
<el-radio-button label="7d">近 7 天</el-radio-button>
|
||||
<el-radio-button label="30d">近 30 天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<v-chart :option="trendOption" autoresize style="height: 320px" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>订单状态</span>
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<v-chart :option="pieOption" autoresize style="height: 320px" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="chart-row">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>热销商品 Top 10</span>
|
||||
<el-tag size="small" type="info">按销量</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<v-chart :option="barOption" autoresize style="height: 360px" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: $color-text-secondary;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="content">
|
||||
<h1>404</h1>
|
||||
<p>您访问的页面不存在</p>
|
||||
<el-button type="primary" @click="router.push('/')">返回首页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.not-found {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-page;
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: 96px;
|
||||
color: $brand-primary;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
p {
|
||||
color: $color-text-secondary;
|
||||
margin: 8px 0 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import type { LoginParams } from '@/types/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive<LoginParams>({
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
captchaKey: '',
|
||||
captchaCode: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
|
||||
],
|
||||
captchaCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
// 1. 登录
|
||||
await userStore.login({
|
||||
username: form.username,
|
||||
password: form.password
|
||||
})
|
||||
// 2. 拉取管理员信息
|
||||
try {
|
||||
await userStore.fetchAdminInfo()
|
||||
} catch {
|
||||
/* 拉取失败也允许继续 */
|
||||
}
|
||||
ElMessage.success('登录成功')
|
||||
// 3. 跳转到目标页
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
await router.push(redirect)
|
||||
} catch (e) {
|
||||
/* 拦截器已提示错误 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg">
|
||||
<div class="blob blob-1" />
|
||||
<div class="blob blob-2" />
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="login-left">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">S</div>
|
||||
<div class="brand-text">
|
||||
<h1>零食商城</h1>
|
||||
<p>Snack Mall Admin Console</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome">
|
||||
<h2>欢迎回来 👋</h2>
|
||||
<p>登录管理后台,开启高效运营之旅</p>
|
||||
</div>
|
||||
<ul class="features">
|
||||
<li>📊 实时数据可视化</li>
|
||||
<li>🛒 完整商品/订单管理</li>
|
||||
<li>🎯 营销活动精准运营</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="login-right">
|
||||
<h2 class="form-title">账号登录</h2>
|
||||
<p class="form-subtitle">请使用您的管理员账号登录</p>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
class="login-form"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
:prefix-icon="User"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
class="submit-btn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="tips">
|
||||
<span>默认账号:admin / 123456</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 50%, #bfdbfe 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.blob-1 {
|
||||
width: 480px;
|
||||
height: 480px;
|
||||
background: #3b82f6;
|
||||
top: -120px;
|
||||
left: -120px;
|
||||
}
|
||||
.blob-2 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
background: #1e40af;
|
||||
bottom: -100px;
|
||||
right: -80px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 920px;
|
||||
max-width: 95vw;
|
||||
min-height: 540px;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 64px rgba(30, 64, 175, 0.12);
|
||||
}
|
||||
|
||||
.login-left {
|
||||
flex: 1;
|
||||
padding: 48px 40px;
|
||||
background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.brand-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.brand-text p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.welcome p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-right {
|
||||
flex: 1;
|
||||
padding: 56px 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
.form-subtitle {
|
||||
font-size: 14px;
|
||||
color: $color-text-secondary;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-input__wrapper) {
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-card {
|
||||
width: 95vw;
|
||||
min-height: auto;
|
||||
}
|
||||
.login-left {
|
||||
display: none;
|
||||
}
|
||||
.login-right {
|
||||
padding: 40px 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Plus, Refresh, Edit, Delete, Top } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getNoticePageApi, createNoticeApi, updateNoticeStatusApi } from '@/api/notice'
|
||||
import type { NoticeItem } from '@/api/notice'
|
||||
import { NOTICE_TYPE_MAP } from '@/utils/dict'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import SearchBar from '@/components/common/SearchBar.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<NoticeItem[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ current: 1, size: 10 })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
title: '',
|
||||
type: 0,
|
||||
isTop: 0,
|
||||
content: '',
|
||||
startTime: '',
|
||||
endTime: ''
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getNoticePageApi(query)
|
||||
list.value = res.records
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, { id: 0, title: '', type: 0, isTop: 0, content: '', startTime: '', endTime: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await createNoticeApi(form)
|
||||
ElMessage.success('发布成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleToggleStatus(row: NoticeItem) {
|
||||
await updateNoticeStatusApi(row.id, row.status === 1 ? 0 : 1)
|
||||
ElMessage.success('操作成功')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleDelete(row: NoticeItem) {
|
||||
await ElMessageBox.confirm(`确定删除公告【${row.title}】吗?`, '提示', { type: 'warning' })
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
|
||||
function typeTag(t: number) {
|
||||
return t === 0 ? '' : t === 1 ? 'danger' : 'warning'
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>系统公告</h2>
|
||||
<p class="page-desc">发布系统通知、营销公告,支持定时上下线</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openAdd">发布公告</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<SearchBar show-divider>
|
||||
<el-button :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||
</SearchBar>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column label="公告标题" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="title-cell">
|
||||
<el-tag v-if="row.isTop === 1" type="danger" size="small" effect="dark">
|
||||
<el-icon><Top /></el-icon> 置顶
|
||||
</el-tag>
|
||||
<span class="title-text">{{ row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTag(row.type) as any" size="small">
|
||||
{{ NOTICE_TYPE_MAP[row.type as keyof typeof NOTICE_TYPE_MAP] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" width="280">
|
||||
<template #default="{ row }">
|
||||
<span class="time-cell">
|
||||
{{ formatDate(row.startTime, 'MM-DD HH:mm') }} ~ {{ formatDate(row.endTime, 'MM-DD HH:mm') }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-dot', row.status === 1 ? 'success' : 'info']">
|
||||
{{ row.status === 1 ? '已上线' : '已下线' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" :icon="Edit">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
size="small"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '下线' : '上线' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" :icon="Delete" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="query.current"
|
||||
v-model:page-size="query.size"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="发布公告" width="640px">
|
||||
<el-form :model="form" label-width="100px" size="large">
|
||||
<el-form-item label="公告标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入公告标题" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="公告类型">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio-button :value="0">普通</el-radio-button>
|
||||
<el-radio-button :value="1">重要</el-radio-button>
|
||||
<el-radio-button :value="2">活动</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否置顶">
|
||||
<el-switch
|
||||
v-model="form.isTop"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="置顶"
|
||||
inactive-text="不置顶"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期" required>
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
placeholder="生效时间"
|
||||
style="width: 45%"
|
||||
/>
|
||||
<span style="margin: 0 8px">至</span>
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
placeholder="失效时间"
|
||||
style="width: 45%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="公告内容" required>
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入公告正文"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
.time-cell {
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Search, Refresh, View } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getOrderPageApi, deliverOrderApi, cancelOrderApi } from '@/api/order'
|
||||
import type { OrderItem } from '@/api/order'
|
||||
import { ORDER_STATUS_MAP } from '@/utils/dict'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import SearchBar from '@/components/common/SearchBar.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<OrderItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const query = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
status: undefined as number | undefined
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getOrderPageApi(query)
|
||||
list.value = res.records
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status: number) {
|
||||
return status === 1
|
||||
? 'warning'
|
||||
: status === 2
|
||||
? 'primary'
|
||||
: status === 3
|
||||
? 'success'
|
||||
: status === 4
|
||||
? 'info'
|
||||
: 'danger'
|
||||
}
|
||||
|
||||
async function handleDeliver(row: OrderItem) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`请输入【${row.orderNo}】的物流单号`,
|
||||
'订单发货',
|
||||
{ confirmButtonText: '发货', cancelButtonText: '取消' }
|
||||
)
|
||||
await deliverOrderApi(row.id, { company: '顺丰速运', trackingNo: value })
|
||||
ElMessage.success('发货成功')
|
||||
fetchData()
|
||||
} catch {
|
||||
/* cancel */
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(row: OrderItem) {
|
||||
await ElMessageBox.confirm(`确定要取消订单【${row.orderNo}】吗?`, '提示', { type: 'warning' })
|
||||
await cancelOrderApi(row.id)
|
||||
ElMessage.success('已取消')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>订单管理</h2>
|
||||
<p class="page-desc">查看和处理用户订单</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<SearchBar show-divider>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="订单号 / 收件人"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="fetchData"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="订单状态" clearable style="width: 140px">
|
||||
<el-option
|
||||
v-for="(s, k) in ORDER_STATUS_MAP"
|
||||
:key="k"
|
||||
:label="s.text"
|
||||
:value="Number(k)"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="fetchData">搜索</el-button>
|
||||
<el-button :icon="Refresh" @click="query.keyword = ''; query.status = undefined; fetchData()">重置</el-button>
|
||||
</SearchBar>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="orderNo" label="订单号" width="180" />
|
||||
<el-table-column prop="username" label="用户" width="100" />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="amount">¥ {{ row.payAmount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusClass(row.status) as any" size="small">
|
||||
{{ ORDER_STATUS_MAP[row.status as keyof typeof ORDER_STATUS_MAP].text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receiverName" label="收件人" width="100" />
|
||||
<el-table-column prop="receiverPhone" label="电话" width="140" />
|
||||
<el-table-column prop="createTime" label="下单时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" :icon="View">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
link
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleDeliver(row)"
|
||||
>发货</el-button>
|
||||
<el-button
|
||||
v-if="row.status < 2"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleCancel(row)"
|
||||
>取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="query.current"
|
||||
v-model:page-size="query.size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="fetchData"
|
||||
@size-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.amount {
|
||||
color: $color-danger;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getCategoryListApi } from '@/api/product'
|
||||
import type { CategoryItem } from '@/api/product'
|
||||
|
||||
const list = ref<CategoryItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getCategoryListApi()
|
||||
list.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
ElMessage.info('新增分类功能演示')
|
||||
}
|
||||
async function handleEdit(row: CategoryItem) {
|
||||
ElMessage.info(`编辑:${row.name}`)
|
||||
}
|
||||
async function handleDelete(row: CategoryItem) {
|
||||
await ElMessageBox.confirm(`确定要删除分类【${row.name}】吗?`, '提示', { type: 'warning' })
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>商品分类</h2>
|
||||
<p class="page-desc">树形结构管理商品分类,支持多级嵌套</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus">新增分类</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" v-loading="loading">
|
||||
<el-table :data="list" row-key="id" :tree-props="{ children: 'children' }" default-expand-all>
|
||||
<el-table-column prop="name" label="分类名称" min-width="240" />
|
||||
<el-table-column label="层级" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.level === 1 ? 'primary' : 'info'" size="small">
|
||||
{{ row.level === 1 ? '一级分类' : '二级分类' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="100" align="center" />
|
||||
<el-table-column label="操作" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="Edit" size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="primary" :icon="Plus" size="small">新增子分类</el-button>
|
||||
<el-button link type="danger" :icon="Delete" size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Search, Refresh, Plus, Picture } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getProductPageApi, updateProductStatusApi } from '@/api/product'
|
||||
import type { ProductItem } from '@/api/product'
|
||||
import SearchBar from '@/components/common/SearchBar.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<ProductItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const query = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
status: undefined as number | undefined
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getProductPageApi(query)
|
||||
list.value = res.records
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.current = 1
|
||||
fetchData()
|
||||
}
|
||||
function handleReset() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
query.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleStatusChange(row: ProductItem) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${row.status === 1 ? '下架' : '上架'}【${row.name}】吗?`,
|
||||
'提示',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await updateProductStatusApi(row.id, row.status === 1 ? 0 : 1)
|
||||
ElMessage.success('操作成功')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>商品列表</h2>
|
||||
<p class="page-desc">管理零食商城所有商品,支持上下架、规格、库存等</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus">发布商品</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<SearchBar show-divider>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索商品名称"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="商品状态" clearable style="width: 140px">
|
||||
<el-option label="已上架" :value="1" />
|
||||
<el-option label="已下架" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</SearchBar>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column type="index" label="#" width="60" align="center" />
|
||||
<el-table-column label="商品" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<el-image
|
||||
:src="row.mainImage"
|
||||
fit="cover"
|
||||
class="cell-image"
|
||||
:preview-src-list="[row.mainImage]"
|
||||
hide-on-click-modal
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-error">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span class="product-name">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="售价" width="120" align="center">
|
||||
<template #default="{ row }">¥ {{ row.price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stock" label="库存" width="100" align="center" />
|
||||
<el-table-column prop="sales" label="销量" width="100" align="center" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-dot', row.status === 1 ? 'success' : 'info']">
|
||||
{{ row.status === 1 ? '已上架' : '已下架' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
size="small"
|
||||
@click="handleStatusChange(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '下架' : '上架' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="query.current"
|
||||
v-model:page-size="query.size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="fetchData"
|
||||
@size-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.product-name {
|
||||
font-weight: 500;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
.image-error {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #c0c4cc;
|
||||
background: $bg-page;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Plus, Refresh, VideoPlay, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSeckillPageApi, createSeckillApi, endSeckillApi } from '@/api/seckill'
|
||||
import type { SeckillActivity } from '@/api/seckill'
|
||||
import { SECKILL_STATUS_MAP } from '@/utils/dict'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import SearchBar from '@/components/common/SearchBar.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<SeckillActivity[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const query = reactive({ current: 1, size: 10 })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: ''
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getSeckillPageApi(query)
|
||||
list.value = res.records
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, { name: '', startTime: '', endTime: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await createSeckillApi(form)
|
||||
ElMessage.success('创建成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleEnd(row: SeckillActivity) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要提前结束活动【${row.name}】吗?结束后商品将恢复原价`,
|
||||
'提示',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await endSeckillApi(row.id)
|
||||
ElMessage.success('活动已结束')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function statusType(s: number) {
|
||||
return s === 0 ? 'info' : s === 1 ? 'success' : 'warning'
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>限时抢购</h2>
|
||||
<p class="page-desc">管理限时秒杀活动场次与商品</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openAdd">创建活动</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="overview-card">
|
||||
<div class="ov-label">进行中活动</div>
|
||||
<div class="ov-value text-success">2</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="overview-card">
|
||||
<div class="ov-label">未开始活动</div>
|
||||
<div class="ov-value text-primary">1</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="overview-card">
|
||||
<div class="ov-label">总参与人次</div>
|
||||
<div class="ov-value">8,562</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="overview-card">
|
||||
<div class="ov-label">活动总销售额</div>
|
||||
<div class="ov-value text-danger">¥ 156,890</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<SearchBar show-divider>
|
||||
<el-button :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||
</SearchBar>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="name" label="活动名称" min-width="220" />
|
||||
<el-table-column label="开始时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.startTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status) as any" size="small">
|
||||
{{ SECKILL_STATUS_MAP[row.status as keyof typeof SECKILL_STATUS_MAP] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small">查看商品</el-button>
|
||||
<el-button link type="primary" size="small">数据统计</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:icon="VideoPlay"
|
||||
@click="handleEnd(row)"
|
||||
>结束活动</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="query.current"
|
||||
v-model:page-size="query.size"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="创建抢购活动" width="540px">
|
||||
<el-form :model="form" label-width="100px" size="large">
|
||||
<el-form-item label="活动名称" required>
|
||||
<el-input v-model="form.name" placeholder="如:618 限时秒杀" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间" required>
|
||||
<el-date-picker v-model="form.startTime" type="datetime" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" required>
|
||||
<el-date-picker v-model="form.endTime" type="datetime" style="width: 100%" />
|
||||
</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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.overview-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
.ov-label {
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
.ov-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.text-success { color: $color-success; }
|
||||
.text-primary { color: $brand-primary; }
|
||||
.text-danger { color: $color-danger; }
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getUserPageApi, updateUserStatusApi } from '@/api/user'
|
||||
import type { UserItem } from '@/api/user'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import SearchBar from '@/components/common/SearchBar.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<UserItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const query = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
status: undefined as number | undefined
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getUserPageApi(query)
|
||||
list.value = res.records
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus(row: UserItem) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${row.status === 1 ? '禁用' : '启用'}用户【${row.username}】吗?`,
|
||||
'提示',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await updateUserStatusApi(row.id, row.status === 1 ? 0 : 1)
|
||||
ElMessage.success('操作成功')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>用户管理</h2>
|
||||
<p class="page-desc">查看用户信息、启用/禁用账号</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<SearchBar show-divider>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="用户名 / 手机号"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="fetchData"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="账号状态" clearable style="width: 140px">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="已禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="fetchData">搜索</el-button>
|
||||
<el-button :icon="Refresh" @click="query.keyword = ''; query.status = undefined; fetchData()">重置</el-button>
|
||||
</SearchBar>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column type="index" label="#" width="60" align="center" />
|
||||
<el-table-column label="用户" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<el-avatar :size="36" :src="row.avatar">{{ row.username.charAt(0) }}</el-avatar>
|
||||
<span class="username">{{ row.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" width="160" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-dot', row.status === 1 ? 'success' : 'danger']">
|
||||
{{ row.status === 1 ? '正常' : '已禁用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="注册时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small">查看详情</el-button>
|
||||
<el-button link type="primary" size="small">订单记录</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="row.status === 1 ? 'danger' : 'success'"
|
||||
size="small"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="query.current"
|
||||
v-model:page-size="query.size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="fetchData"
|
||||
@size-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.username {
|
||||
font-weight: 500;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig, presetUno, presetAttributify } from 'unocss'
|
||||
|
||||
/**
|
||||
* UnoCSS 配置 — 与 Element Plus 协作
|
||||
* Element Plus 提供组件库(含丰富图标 @element-plus/icons-vue)
|
||||
* UnoCSS 仅提供工具类辅助,不使用 preset-icons 避免 CDN 拉取失败
|
||||
*/
|
||||
export default defineConfig({
|
||||
presets: [presetUno(), presetAttributify()],
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
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: {
|
||||
// 全局注入设计系统变量到每个 .scss / .vue<style lang="scss"> 文件
|
||||
// 配合子文件中的 @use "@/styles/variables.scss" as * 一起使用
|
||||
additionalData: `@use "@/styles/variables.scss" as *;\n`
|
||||
}
|
||||
}
|
||||
},
|
||||
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']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue