feat: 添加标签导航栏并重构路由和布局

- 添加TabsNav组件实现标签页导航功能
- 重构BasicLayout布局结构,优化样式和容器布局
- 重写SideMenu组件的菜单项提取逻辑
- 分离全局样式文件,将index.scss拆分为多个独立文件
- 添加Mock拦截功能,开发环境可使用本地假数据
- 优化登录流程,调整权限验证逻辑

refactor: 重构路由配置和权限验证逻辑

- 将路由配置合并到主路由文件,移除动态路由挂载
- 优化permission.ts中的登录验证流程
- 修改API调用方式,适配请求拦截器的数据解包
- 更新package.json依赖包排序

feat: 实现客服消息和优惠券管理功能

- 开发完整的客服消息页面,支持会话管理和消息收发
- 实现优惠券管理页面,包含列表展示和新增编辑功能
- 添加相关API接口和类型定义
```
This commit is contained in:
Yuhang Wu 2026-06-02 14:25:53 +08:00
parent 7f5e67c62b
commit 1701bdb800
32 changed files with 7104 additions and 269 deletions

View File

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

View File

@ -15,43 +15,46 @@
"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",
"@vueuse/core": "^12.0.0",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"nprogress": "^0.2.0",
"echarts": "^5.5.1",
"element-plus": "^2.9.1",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"@vueuse/core": "^12.0.0",
"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/node": "^22.10.2",
"@types/lodash-es": "^4.17.12",
"@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",
"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",
"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",
"unocss": "^0.65.4"
"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"

4554
admin-snack/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -15,6 +15,7 @@ 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()
@ -22,7 +23,6 @@ const appStore = useAppStore()
const userStore = useUserStore()
const collapsed = computed(() => appStore.sidebarCollapsed)
const activeMenu = computed(() => route.path)
function toggleSidebar() {
appStore.toggleSidebar()
@ -74,7 +74,7 @@ async function handleCommand(cmd: string) {
<SideMenu :collapsed="collapsed" />
</el-aside>
<el-container>
<el-container class="right-container">
<!-- 顶栏 -->
<el-header class="header">
<div class="header-left">
@ -114,6 +114,9 @@ async function handleCommand(cmd: string) {
</div>
</el-header>
<!-- 标签导航栏 -->
<TabsNav />
<!-- 主内容区 -->
<el-main class="main">
<router-view v-slot="{ Component, route: r }">
@ -131,6 +134,7 @@ async function handleCommand(cmd: string) {
<style lang="scss" scoped>
.basic-layout {
height: 100vh;
overflow: hidden;
}
.sidebar {
@ -138,6 +142,7 @@ async function handleCommand(cmd: string) {
transition: width 0.25s ease;
overflow: hidden;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
flex-shrink: 0;
}
.logo {
@ -170,6 +175,13 @@ async function handleCommand(cmd: string) {
}
}
.right-container {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
}
.header {
background: #fff;
height: $header-height;
@ -180,6 +192,7 @@ async function handleCommand(cmd: string) {
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 {
@ -221,5 +234,6 @@ async function handleCommand(cmd: string) {
background: $bg-page;
padding: 20px;
overflow-y: auto;
flex: 1;
}
</style>

View File

@ -30,10 +30,12 @@ function getMenuItems(routes: RouteRecordRaw[]) {
return items
}
// router.options.routes
// hidden
const menuItems = computed(() => {
const allRoutes = router.options.routes.flatMap((r) => r.children || [])
return getMenuItems(allRoutes as RouteRecordRaw[])
const topRoutes = router.options.routes.filter(
(r) => !r.meta?.hidden && r.path !== '/:pathMatch(.*)*'
)
return getMenuItems(topRoutes as RouteRecordRaw[])
})
const activePath = computed(() => route.path)

View File

@ -0,0 +1,185 @@
<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) {
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)
}
}
function handleRefresh() {
//
router.replace({ path: '/redirect' + route.path })
}
//
function showContextMenu(e: MouseEvent, path: string) {
e.preventDefault()
// el-dropdown ref
}
</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="tabsStore.closeOthers(route.path)">
关闭其他
</el-dropdown-item>
<el-dropdown-item @click="tabsStore.closeRight(route.path)">
关闭右侧
</el-dropdown-item>
<el-dropdown-item divided @click="tabsStore.closeAll(); router.push('/dashboard')">
关闭所有
</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>

View File

@ -7,10 +7,12 @@ 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/index.scss'
import './styles/reset.scss'
import './styles/element.scss'
import './styles/transitions.scss'
import 'virtual:uno.css'
import 'nprogress/nprogress.css'
@ -20,6 +22,9 @@ import './assets/styles/main.scss'
// 权限控制
import './permission'
// Mock 拦截(开发阶段用本地假数据,启动后端后可注释此行)
import.meta.env.DEV && import('./mock')
const app = createApp(App)
app.use(pinia)

View File

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

View File

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

View File

@ -12,6 +12,7 @@ router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
// 白名单login/404
if (WHITE_LIST.includes(to.path)) {
if (to.path === '/login' && userStore.isLoggedIn) {
next('/')
@ -21,26 +22,23 @@ router.beforeEach(async (to, _from, next) => {
return
}
// 未登录 → 跳登录
if (!userStore.isLoggedIn) {
next(`/login?redirect=${to.path}`)
next(`/login?redirect=${encodeURIComponent(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}`)
next(`/login?redirect=${encodeURIComponent(to.path)}`)
NProgress.done()
return
}
return
}
next()

View File

@ -109,11 +109,6 @@ const asyncRoutes: RouteRecordRaw[] = [
meta: { title: '客服消息', icon: 'ChatDotRound' }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/404',
meta: { hidden: true }
}
]

View File

@ -1,149 +1,148 @@
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 }
}
]
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: baseRoutes,
scrollBehavior: () => ({ left: 0, top: 0 })
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

View File

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

View File

@ -17,13 +17,15 @@ export const useUserStore = defineStore(
const avatar = computed(() => adminInfo.value?.avatar ?? '')
async function login(params: LoginParams) {
const { data } = await loginApi(params)
// request 拦截器已解包出业务 data这里直接拿到 { token }
const data = await loginApi(params)
token.value = data.token
return data
}
async function fetchAdminInfo() {
const { data } = await getAdminInfoApi()
// request 拦截器已解包出业务 data这里直接拿到 AdminInfo
const data = await getAdminInfoApi()
adminInfo.value = data
return data
}

View File

@ -1,5 +1,6 @@
// Element Plus 主题覆盖 统一品牌色 + 圆角风格
@use './variables.scss' as *;
// variables.scss 已由 vite.config.ts additionalData 自动注入
/* 主按钮 */
.el-button--primary {

View File

@ -1,5 +1,3 @@
// 全局样式入口
@use './variables.scss' as *;
@use './reset.scss';
@use './element.scss';
@use './transitions.scss';
// 此文件已弃用
// 全局样式改为在 main.ts 中分开 import 各子文件
// 每个子文件由 Vite 独立处理additionalData 中注入的变量可正常访问

88
admin-snack/src/types/auto-imports.d.ts vendored Normal file
View File

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

54
admin-snack/src/types/components.d.ts vendored Normal file
View File

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

View File

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

View File

@ -1,17 +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">
<div class="page-header"><h2>客服消息</h2></div>
<el-card shadow="never">
<el-empty description="客服消息功能开发中" />
</el-card>
<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>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
.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>

View File

@ -1,17 +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">
<div class="page-header"><h2>优惠券</h2></div>
<el-card shadow="never">
<el-empty description="优惠券管理功能开发中" />
<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>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
.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>

View File

@ -35,13 +35,21 @@ async function handleLogin() {
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) || '/'
router.push(redirect)
await router.push(redirect)
} catch (e) {
/* 拦截器已提示错误 */
} finally {

View File

@ -1,17 +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">
<div class="page-header"><h2>系统公告</h2></div>
<el-card shadow="never">
<el-empty description="公告管理功能开发中" />
<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>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
.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>

View File

@ -1,17 +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">
<div class="page-header"><h2>订单管理</h2></div>
<el-card shadow="never">
<el-empty description="订单管理功能开发中" />
<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>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
.amount {
color: $color-danger;
font-weight: 600;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding: 16px 20px;
}
</style>

View File

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

View File

@ -1,26 +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">
<div class="page-container">
<div class="page-header">
<h2>商品列表</h2>
<div>
<h2>商品列表</h2>
<p class="page-desc">管理零食商城所有商品支持上下架规格库存等</p>
</div>
<el-button type="primary" :icon="Plus">发布商品</el-button>
</div>
<el-card shadow="never">
<el-empty description="商品管理功能开发中" />
<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>
.page {
.product-cell {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
gap: 10px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
.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>

View File

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

View File

@ -1,17 +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">
<div class="page-header"><h2>用户管理</h2></div>
<el-card shadow="never">
<el-empty description="用户管理功能开发中" />
<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>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-header h2 { margin: 0; font-size: 20px; }
.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>

View File

@ -1,18 +1,12 @@
import { defineConfig, presetUno, presetAttributify, presetIcons } from 'unocss'
import { defineConfig, presetUno, presetAttributify } from 'unocss'
/**
* UnoCSS Element Plus
* Element Plus UnoCSS
* Element Plus @element-plus/icons-vue
* UnoCSS 使 preset-icons CDN
*/
export default defineConfig({
presets: [
presetUno(),
presetAttributify(),
presetIcons({
scale: 1.2,
cdn: 'https://cdn.jsdelivr.net/npm/@iconify-json/carbon-icons'
})
],
presets: [presetUno(), presetAttributify()],
theme: {
colors: {
brand: {
@ -32,6 +26,5 @@ export default defineConfig({
shortcuts: {
'flex-center': 'flex items-center justify-center',
'flex-between': 'flex items-center justify-between'
},
safelist: ['i-carbon-sun', 'i-carbon-moon']
}
})

View File

@ -36,7 +36,9 @@ export default defineConfig(({ mode }) => {
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`
// 全局注入设计系统变量到每个 .scss / .vue<style lang="scss"> 文件
// 配合子文件中的 @use "@/styles/variables.scss" as * 一起使用
additionalData: `@use "@/styles/variables.scss" as *;\n`
}
}
},