feat: 初始化零食商城管理后台项目

- 添加开发、测试、生产环境配置文件
- 配置 Git 忽略规则和代码格式化规则
- 创建项目 README 文档和基础 HTML 模板
- 配置 ESLint 和 Prettier 代码规范
- 实现认证、聊天、优惠券、仪表盘等 API 接口
- 创建基础布局组件和路由权限控制
- 集成状态管理、UI 组件库和图表库
```
This commit is contained in:
Yuhang Wu 2026-06-02 10:05:37 +08:00
parent 3ea0678c25
commit 7f5e67c62b
59 changed files with 2809 additions and 0 deletions

View File

@ -0,0 +1,4 @@
# 开发环境
VITE_APP_TITLE=零食商城管理后台
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_ENV=development

View File

@ -0,0 +1,3 @@
VITE_APP_TITLE=零食商城管理后台
VITE_API_BASE_URL=
VITE_APP_ENV=production

3
admin-snack/.env.test Normal file
View File

@ -0,0 +1,3 @@
VITE_APP_TITLE=零食商城管理后台
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_ENV=test

6
admin-snack/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
*.log
.vscode
.idea

View File

@ -0,0 +1,16 @@
{
"importOrder": [
"^vue",
"^vue-router",
"^pinia",
"^axios",
"^element-plus",
"^echarts",
"^dayjs",
"^[a-zA-Z]",
"^@/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"importOrderParsePlugins": ["typescript", "vue"]
}

View File

@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"vueIndentScriptAndStyle": false
}

146
admin-snack/README.md Normal file
View File

@ -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` 项目。
## 浏览器支持
现代浏览器 + ES2020Chrome 90+, Edge 90+, Firefox 90+, Safari 14+

View File

@ -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()
]

14
admin-snack/index.html Normal file
View File

@ -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>

59
admin-snack/package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "admin-snack",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "零食商城管理后台",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build:prod": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx --fix",
"format": "prettier --write \"src/**/*.{vue,ts,js,tsx,jsx,css,scss,md,json}\""
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"pinia-plugin-persistedstate": "^4.2.0",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1",
"echarts": "^5.5.1",
"vue-echarts": "^7.0.3",
"dayjs": "^1.11.13",
"nprogress": "^0.2.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"@vueuse/core": "^12.0.0",
"wangeditor": "^4.7.15"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/lodash-es": "^4.17.12",
"@types/js-cookie": "^3.0.6",
"@types/nprogress": "^0.2.3",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"vite": "^6.0.5",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"vue-tsc": "^2.2.0",
"typescript": "^5.7.2",
"@typescript-eslint/parser": "^8.18.0",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@vue/eslint-config-typescript": "^14.1.4",
"eslint": "^9.17.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2",
"sass": "^1.83.0",
"unocss": "^0.65.4"
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@ -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

16
admin-snack/src/App.vue Normal file
View File

@ -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>

View File

@ -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'
})
}

View File

@ -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
})
}

View File

@ -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 }
})
}

View File

@ -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'
})
}

View File

@ -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' }
})
}

View File

@ -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 }
})
}

View File

@ -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'
})
}

View File

@ -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 }
})
}

View File

@ -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'
})
}

View File

@ -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 }
})
}

View File

@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount } 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'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const userStore = useUserStore()
const collapsed = computed(() => appStore.sidebarCollapsed)
const activeMenu = computed(() => route.path)
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)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
})
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>
<!-- 顶栏 -->
<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>
<!-- 主内容区 -->
<el-main class="main">
<router-view v-slot="{ Component, route: r }">
<transition name="slide-up" mode="out-in">
<keep-alive>
<component :is="Component" :key="r.fullPath" />
</keep-alive>
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<style lang="scss" scoped>
.basic-layout {
height: 100vh;
}
.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);
}
.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;
}
}
.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;
}
.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;
}
</style>

View File

@ -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>

View File

@ -0,0 +1,82 @@
<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
}
// router.options.routes
const menuItems = computed(() => {
const allRoutes = router.options.routes.flatMap((r) => r.children || [])
return getMenuItems(allRoutes 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>

28
admin-snack/src/main.ts Normal file
View File

@ -0,0 +1,28 @@
import { createApp } from 'vue'
import App from './App.vue'
// 状态管理
import pinia from './stores'
// 路由
import router from './router'
// 全局样式
import 'element-plus/theme-chalk/dark/css-vars.css'
import 'element-plus/dist/index.css'
import './styles/index.scss'
import 'virtual:uno.css'
import 'nprogress/nprogress.css'
// 业务样式
import './assets/styles/main.scss'
// 权限控制
import './permission'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,51 @@
import router from '@/router'
import NProgress from 'nprogress'
import { useUserStore } from '@/stores/modules/user'
NProgress.configure({ showSpinner: false })
const WHITE_LIST = ['/login', '/404']
router.beforeEach(async (to, _from, next) => {
NProgress.start()
document.title = (to.meta.title as string) || '零食商城管理后台'
const userStore = useUserStore()
if (WHITE_LIST.includes(to.path)) {
if (to.path === '/login' && userStore.isLoggedIn) {
next('/')
} else {
next()
}
return
}
if (!userStore.isLoggedIn) {
next(`/login?redirect=${to.path}`)
NProgress.done()
return
}
// 首次登录加载管理员信息
if (!userStore.adminInfo) {
try {
await userStore.fetchAdminInfo()
// 动态挂载业务路由
const asyncRoutes = (await import('@/router/async-routes')).default
asyncRoutes.forEach((route) => router.addRoute(route))
next({ ...to, replace: true })
} catch (err) {
userStore.clearAuth()
next(`/login?redirect=${to.path}`)
NProgress.done()
}
return
}
next()
})
router.afterEach(() => {
NProgress.done()
})

View File

@ -0,0 +1,120 @@
import type { RouteRecordRaw } from 'vue-router'
/**
*
* router/index.ts asyncRoutes
*/
const asyncRoutes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'Odometer', keepAlive: true }
}
]
},
{
path: '/product',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/product/list',
meta: { title: '商品管理', icon: 'Goods' },
children: [
{
path: 'list',
name: 'ProductList',
component: () => import('@/views/product/list.vue'),
meta: { title: '商品列表', icon: 'List' }
},
{
path: 'category',
name: 'ProductCategory',
component: () => import('@/views/product/category.vue'),
meta: { title: '商品分类', icon: 'Menu' }
}
]
},
{
path: '/order',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/order/list',
children: [
{
path: 'list',
name: 'OrderList',
component: () => import('@/views/order/list.vue'),
meta: { title: '订单管理', icon: 'Document' }
}
]
},
{
path: '/user',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/user/list',
children: [
{
path: 'list',
name: 'UserList',
component: () => import('@/views/user/list.vue'),
meta: { title: '用户管理', icon: 'User' }
}
]
},
{
path: '/marketing',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/marketing/coupon',
meta: { title: '营销管理', icon: 'Present' },
children: [
{
path: 'coupon',
name: 'CouponList',
component: () => import('@/views/coupon/list.vue'),
meta: { title: '优惠券', icon: 'Ticket' }
},
{
path: 'seckill',
name: 'SeckillList',
component: () => import('@/views/seckill/list.vue'),
meta: { title: '限时抢购', icon: 'AlarmClock' }
}
]
},
{
path: '/notice',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/notice/list',
children: [
{
path: 'list',
name: 'NoticeList',
component: () => import('@/views/notice/list.vue'),
meta: { title: '系统公告', icon: 'Bell' }
}
]
},
{
path: '/chat',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/chat/list',
children: [
{
path: 'list',
name: 'ChatList',
component: () => import('@/views/chat/list.vue'),
meta: { title: '客服消息', icon: 'ChatDotRound' }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/404',
meta: { hidden: true }
}
]
export default asyncRoutes

View File

@ -0,0 +1,149 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
/**
*
*/
const baseRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', hidden: true }
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { title: '404', hidden: true }
}
]
/**
*
* meta
* title -
* icon - Element Plus
* hidden -
* roles - 访
* keepAlive -
*/
export const asyncRoutes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'Odometer', keepAlive: true }
}
]
},
{
path: '/product',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/product/list',
meta: { title: '商品管理', icon: 'Goods' },
children: [
{
path: 'list',
name: 'ProductList',
component: () => import('@/views/product/list.vue'),
meta: { title: '商品列表', icon: 'List' }
},
{
path: 'category',
name: 'ProductCategory',
component: () => import('@/views/product/category.vue'),
meta: { title: '商品分类', icon: 'Menu' }
}
]
},
{
path: '/order',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/order/list',
children: [
{
path: 'list',
name: 'OrderList',
component: () => import('@/views/order/list.vue'),
meta: { title: '订单管理', icon: 'Document' }
}
]
},
{
path: '/user',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/user/list',
children: [
{
path: 'list',
name: 'UserList',
component: () => import('@/views/user/list.vue'),
meta: { title: '用户管理', icon: 'User' }
}
]
},
{
path: '/marketing',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/marketing/coupon',
meta: { title: '营销管理', icon: 'Present' },
children: [
{
path: 'coupon',
name: 'CouponList',
component: () => import('@/views/coupon/list.vue'),
meta: { title: '优惠券', icon: 'Ticket' }
},
{
path: 'seckill',
name: 'SeckillList',
component: () => import('@/views/seckill/list.vue'),
meta: { title: '限时抢购', icon: 'AlarmClock' }
}
]
},
{
path: '/notice',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/notice/list',
children: [
{
path: 'list',
name: 'NoticeList',
component: () => import('@/views/notice/list.vue'),
meta: { title: '系统公告', icon: 'Bell' }
}
]
},
{
path: '/chat',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/chat/list',
children: [
{
path: 'list',
name: 'ChatList',
component: () => import('@/views/chat/list.vue'),
meta: { title: '客服消息', icon: 'ChatDotRound' }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/404',
meta: { hidden: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes: baseRoutes,
scrollBehavior: () => ({ left: 0, top: 0 })
})
export default router

View File

@ -0,0 +1,7 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia

View File

@ -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']
}
}
)

View File

@ -0,0 +1,62 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, logoutApi, getAdminInfoApi } from '@/api/auth'
import type { LoginParams, AdminInfo } from '@/types/auth'
/**
* / Store
*/
export const useUserStore = defineStore(
'user',
() => {
const token = ref<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) {
const { data } = await loginApi(params)
token.value = data.token
return data
}
async function fetchAdminInfo() {
const { data } = await getAdminInfoApi()
adminInfo.value = data
return data
}
async function logout() {
try {
await logoutApi()
} finally {
clearAuth()
}
}
function clearAuth() {
token.value = ''
adminInfo.value = null
}
return {
token,
adminInfo,
isLoggedIn,
username,
avatar,
login,
logout,
fetchAdminInfo,
clearAuth
}
},
{
persist: {
key: 'snack-admin-user',
pick: ['token', 'adminInfo']
}
}
)

View File

@ -0,0 +1,53 @@
// Element Plus 主题覆盖 统一品牌色 + 圆角风格
@use './variables.scss' as *;
/* 主按钮 */
.el-button--primary {
--el-button-bg-color: #{$brand-primary};
--el-button-border-color: #{$brand-primary};
--el-button-hover-bg-color: #{$brand-primary-light};
--el-button-hover-border-color: #{$brand-primary-light};
--el-button-active-bg-color: #{$brand-primary-dark};
--el-button-active-border-color: #{$brand-primary-dark};
border-radius: $radius-sm;
}
/* 卡片 */
.el-card {
border-radius: $radius-md !important;
border: 1px solid $color-border-light !important;
box-shadow: $shadow-sm !important;
}
/* 菜单 */
.el-menu {
border-right: none !important;
}
/* 表格 */
.el-table {
border-radius: $radius-sm;
th.el-table__cell {
background-color: $bg-page !important;
color: $color-text-regular;
font-weight: 600;
}
}
/* 输入框 */
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: $radius-sm !important;
}
/* 分页 */
.el-pagination {
justify-content: flex-end;
margin-top: 16px;
}
/* Dialog */
.el-dialog {
border-radius: $radius-md !important;
}

View File

@ -0,0 +1,5 @@
// 全局样式入口
@use './variables.scss' as *;
@use './reset.scss';
@use './element.scss';
@use './transitions.scss';

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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
}

View File

@ -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[]
}

17
admin-snack/src/types/shims.d.ts vendored Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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')
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header"><h2>客服消息</h2></div>
<el-card shadow="never">
<el-empty description="客服消息功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header"><h2>优惠券</h2></div>
<el-card shadow="never">
<el-empty description="优惠券管理功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
</style>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,299 @@
<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 {
await userStore.login({
username: form.username,
password: form.password
})
ElMessage.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
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>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header"><h2>系统公告</h2></div>
<el-card shadow="never">
<el-empty description="公告管理功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header"><h2>订单管理</h2></div>
<el-card shadow="never">
<el-empty description="订单管理功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header"><h2>商品分类</h2></div>
<el-card shadow="never">
<el-empty description="分类管理功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
</style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header">
<h2>商品列表</h2>
</div>
<el-card shadow="never">
<el-empty description="商品管理功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
}
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header"><h2>限时抢购</h2></div>
<el-card shadow="never">
<el-empty description="抢购活动管理功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
//
</script>
<template>
<div class="page">
<div class="page-header"><h2>用户管理</h2></div>
<el-card shadow="never">
<el-empty description="用户管理功能开发中" />
</el-card>
</div>
</template>
<style lang="scss" scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
</style>

34
admin-snack/tsconfig.json Normal file
View File

@ -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"]
}

View File

@ -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"
]
}

37
admin-snack/uno.config.ts Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig, presetUno, presetAttributify, presetIcons } from 'unocss'
/**
* UnoCSS Element Plus
* Element Plus UnoCSS
*/
export default defineConfig({
presets: [
presetUno(),
presetAttributify(),
presetIcons({
scale: 1.2,
cdn: 'https://cdn.jsdelivr.net/npm/@iconify-json/carbon-icons'
})
],
theme: {
colors: {
brand: {
50: '#EFF6FF',
100: '#DBEAFE',
200: '#BFDBFE',
300: '#93C5FD',
400: '#60A5FA',
500: '#3B82F6',
600: '#1E40AF',
700: '#1E3A8A',
800: '#1E3A8A',
900: '#172554'
}
}
},
shortcuts: {
'flex-center': 'flex items-center justify-center',
'flex-between': 'flex items-center justify-between'
},
safelist: ['i-carbon-sun', 'i-carbon-moon']
})

View File

@ -0,0 +1,79 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import UnoCSS from 'unocss/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
base: './',
plugins: [
vue(),
vueJsx(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: 'src/types/auto-imports.d.ts',
eslintrc: { enabled: true }
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/types/components.d.ts'
}),
UnoCSS()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`
}
}
},
server: {
host: '0.0.0.0',
port: 5173,
open: true,
cors: true,
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api')
},
'/uploads': {
target: env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true
}
}
},
build: {
target: 'es2020',
outDir: 'dist',
assetsDir: 'static',
sourcemap: false,
minify: 'esbuild',
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
element: ['element-plus', '@element-plus/icons-vue'],
echarts: ['echarts', 'vue-echarts'],
editor: ['wangeditor']
}
}
}
}
}
})